v1.10.0
-
Ctrl+nopens the create form on an issue that already exists (#625).gwm create --issue(#617) derives the whole<type> <issue> <desc>triple from an issue on the forge, and it was CLI-only. The TUI is where a worktree usually gets created, so the one place already showing a worktree list and its linked issues was the one place that still asked for the title to be retyped as a slug and the branch type to be read off the labels by hand.Ctrl+n(andcreate-from-issuein the command palette) opens the form on a single field, the issue number. Enter looks the issue up rather than creating anything; when the answer lands the form becomes the ordinary structured form with the type, the number and the derived slug in it, and a second Enter creates the worktree. Prefilling rather than creating is the point: the slug is a guess about a title, and this is the surface that can show the guess before committing to it. The derivation runs through the very functions the CLI uses, so the two cannot produce different slugs for the same title.Where the CLI has to refuse, the form asks. A non-interactive command has nowhere to ask when the labels name no branch type or name two, which is what
--typeis for; the form lands the cursor on the type selector with everything else filled. A closed issue prefills with a warning rather than refusing, since nothing is written until you confirm. A number that already has a worktree closes the form and names it, matching the CLI’s exit-0 behaviour, and reads the same linkgwm listshows, so a worktree attached by hand withgwm linkcounts too.The lookup runs on the async task spine, never the render path, and a result is applied only when the form asked for that exact number: the form is a second consumer of a message that also fires for the sidebar prefetch and for an explicit refresh. An issue already in the cache prefills straight away rather than waiting for a message that would never arrive, since the fetch coalesces on a cache hit.
Ctrl+tis inert in this mode and the hint row does not offer it. The toggle swaps between the structured triple and the free-form name, which are two ways of typing the same worktree; this one is a two-step mode, left by answering it or by cancelling, so a third stop on the cycle would only make the key unpredictable. -
gwm create --issue <N>opens a worktree for an issue that already exists (#617).gwm newcovered the issue that does not exist yet: it renders the issue from[issue_template.by_type.<type>], creates it, and opens its worktree. Nothing covered the other half, the issue a teammate, a bot, or you last week already filed, where the way through was to open the issue in a browser, read its title, decide which branch type its labels implied, and type a kebab-case slug that restated the title. Three of those four steps were transcription, and the transcription drifted.--issue <N>fetches the issue and derives the triple.<desc>comes from the title with the type’stitle_prefixtaken back off, normalised through the same kebab-case path a hand-typed<desc>goes through and truncated on a word boundary rather than mid-word. The prefix is resolved through the same combinatorgwm newuses when it writes the title, falling back to the issue form’s owntitle:, so the two halves of the flow produce the same slug for the same title.<type>comes from the labels:[issue_template.by_type.*].labelsis the type-to-labels mapgwm newwrites with, read backwards. A type declaring no labels is never a candidate, since an empty list says nothing about which issues belong to it.Nothing is guessed. Labels matching no type, labels matching two, and a repo that never configured the map are three distinct refusals, each naming what it saw and pointing at
--type <TYPE>. A closed issue is refused too, because a worktree for one is usually a wrong number, and--forceproceeds. A worktree that already carries the number is printed and the command exits 0, so the command is safe to re-run; that check runs before the closed-issue refusal, since an issue closes while its worktree is still alive. It reads the same linkgwm listshows, so a worktree attached by hand withgwm link --issuecounts too.--issueis exclusive with the positional triple and with--name, the way--namealready is: the mode is chosen explicitly, never inferred from how many arguments were supplied. Everything after the derivation is the existinggwm createpath unchanged,#{issue}inbranch_patternincluded. An issue title is arbitrary text from the forge, so it reaches the slug through the same normaliser as a hand-typed<desc>rather than around it, and the echoed title, URL and labels are sanitised for the terminal. -
Wopens the Working Tree listing at full size (#592). The sidebar’s Working Tree pane is one block among five in a column that is a fraction of the screen, so a worktree with more than a handful of changed files could only be read two rows at a time throughJ/K.Wnow opens the same file-explorer tree as a full-size overlay: same icons, same per-category colours, the same change counts on the bottom rule, scrolled withj/k,g/G, closed withEsc/q(orWagain, whateverWgets rebound to, see #613 below), and rebindable under[tui.keys.modal.working_tree].The listing is read when the overlay opens rather than taken from the sidebar’s cache, so it does not go blank in the two states where that cache is never built: sidebar hidden, or the Details panel showing stashes. The read runs on a worker and the overlay opens on a loader, so a repository whose untracked walk is slow does not freeze the event loop on the keypress.
The right of each row says how many lines the file gained and lost (
+120 -34), from onegit diffagainstHEADin the same read, so staged and unstaged changes are counted together. A directory, an untracked file and a binary file carry no counts: the first has no diff of its own, and for the other two git counts no lines. The column rides its own rect on the right and is dropped whole on a terminal too narrow to keep it and a readable file name, so the name is never what goes.D/Upage the listing by half a screen, and the key is advertised in both pane footers, matching the commit listing (#593). -
oon the agents overlay resumes the session in the multiplexer (#591). The overlay told you which agent was working where and then left you to get there by hand.adid not help: it is a pin, it changes gwm’s bookkeeping, not where the session runs.oopens a pane running the selected session.In the worktree the overlay is about, not in the session’s recorded directory. A pinned session is pinned precisely because that directory names the wrong tree, and for a pinned Claude session it can be the slug directory under
~/.claude/projectsrather than a worktree at all.Multiplexer only, deliberately. With none active the key says so and does nothing, because the point is to put the session next to gwm and the PTY overlay would cover gwm instead. It opens at the level
mux_open_innames, exactly astdoes. One target stays refused: a zellij tab takes no trailing command in any form.herdr works too, in two steps. None of its levels accepts a trailing command, so gwm opens the container, waits for its new shell to reach a prompt, then types the line in through the pane id herdr’s response carries. All three of
pane split,tab createandworkspace createname a pane to run in. The wait is load-bearing rather than defensive:herdr pane runtypes into the interactive shell instead of exec’ing, so a line sent while the shell is still running its rc files lands in the middle of that output and is dropped, measured on a worktree withdirenvand a nix flake where it took about a minute to settle. The whole sequence therefore runs off the event loop, the status bar readsopening agent pane…meanwhile, and it gives up after two minutes rather than leave a worker running.What the pane runs is
[tui.agent_resume], defaulting toclaude -r {session},codex resume {session},opencode -s {session}andvibe --resume {session}, measured against the installed binaries. They are configuration rather than a hardcoded table because they are four third-party CLIs on their own release cadence. The session id is read out of each tool’s own artefacts, so it reaches the shell quoted through a single-pass expander, the same rule the hook placeholders learned in GHSA-fffq-vg6f-gxqm.A session that has ended resumes without comment; a live one is flagged on the status bar, since resuming it in a second pane while it runs elsewhere may fork or refuse depending on the tool.
-
copens the commit listing full size, with load-more (#593). The sidebar’s Commits pane is a fraction of a sidebar shared with four other blocks, and it stops at 300 commits: seeing further meant leaving gwm for lazygit.cnow paints the same graph on the whole canvas, andmre-reads one page deeper, up to 1500 commits, so history is paged rather than capped. The title carries the row count and a trailing+while a deeper page exists; theload morehint disappears once the revwalk runs out of history or the cap is reached, so the key is never advertised where it would do nothing. The walk runs on a worker, never on the keypress: it sortsTIME | TOPOLOGICAL, so it traverses the whole reachable graph before it yields a row and the limit bounds the output, not the latency. The overlay opens on a loader and fills in when the read lands. The rows are snapshotted at open rather than read from the sidebar cache, which is only rebuilt while the sidebar is open and incommitsmode, so the overlay works with the sidebar hidden or showing stashes. Scroll isj/k,g/G, all rebindable under[tui.keys.modal.commits].Each row carries, on its right, what the hash / initials / subject columns do not say: the author, what the commit changed (
3~ 1+ 2- +120 -34, in the Working Tree pane’s colours, empty categories omitted) and how long ago it landed. Three tiers, picked on what the subject can spare rather than on the terminal width, since the graph is as wide as the branch topology makes it:author · counts · age,counts · age, the age alone, nothing.The counts arrive from a second, chained read, so the log is on screen immediately and the column grows about a second later (up to three on the deepest page). One
git log --raw --numstatover the rows already shown costs about a second where adiff_tree_to_treeper commit costs thirty-three, measured.--diff-merges=first-parentis load-bearing: without itgit logsays nothing at all about a merge, and this project merges rather than squashes.D/Uscroll half a screen, matching the rich PR view. -
Two settings for what a mux spawn opens, and where (#589, #608, #611). The TUI’s
tkey took whatever each backend felt like giving it. Two[tui]keys now say:[tui]mux_open_in = "pane" # "pane" | "tab" | "workspace"mux_pane_direction = "right" # "right" | "down" | "left" | "up", pane only"tab"is a whole screen of its own: a tmux window, a zellij or herdr tab, one thing under three names."workspace"is herdr’s level above a tab and runsherdr workspace create --label <name> --cwd <path> --focus.mux_pane_directionis also the direction a baregwm tmux|zellij|herdr <pattern> --splittakes, and the new--direction <dir>overrides it for one invocation. Both keys cycle live in the Settings panel under the TUI tab.mux_pane_directiontakes all four compass points (#611).leftandupare tmux’s-h -b/-v -b(-bflips the side on the axis-h/-vpicked, measured on 3.7c throughsplit-window -P -F) and zellij’s own words. herdr takes onlyrightanddown, declaring[possible values: right, down], so the other two are refused there rather than substituted:herdr splits only right or down: left and up are tmux and zellij directions"workspace"is refused on tmux and zellij, not downgraded to a tab. Neither has a level there, and quietly opening something else would leave the setting describing what did not happen. The status bar names the backend that cannot and the one that can. (Both have sessions, the structural analogue, but gwm runs inside one: tmux would need two commands to create and switch to a sibling, and zellij refuses to nest sessions.)One caveat, the same shape as the herdr one below: a
[tui.macro*]withopen_in = "mux_pane"falls back to the PTY overlay under"tab"on zellij and under"workspace"on every backend, because those verbs take no trailing command to run. The status bar names which one refused. -
herdr is a third multiplexer backend (#588).
gwm herdr <pattern>opens the matched worktree in a new herdr tab,-psplits the current pane instead, and the TUI’stkey finds herdr the way it finds tmux and zellij. Detection reads$HERDR_ENV, which herdr sets in every pane it manages, and it comes last in the cascade so nothing changes for a tmux or zellij user.Under the hood:
herdr tab create --workspace <id> --label <name> --cwd <path> --focusandherdr pane split --current --direction <right|down> --cwd <path> --focus, verified against a live herdr 0.8.2 rather than its help text. The split needs a direction because herdr’s parser has no default for one; which one it gets is themux_pane_directionentry above. The other two flags are there because herdr’s defaults are the opposite of what the names suggest: without--focusthe tab opens where you cannot see it, and without--workspaceit opens in whichever workspace the server had focused, which is another project’s window as often as not.One surface stays on its old path: a
[tui.macro*]withopen_in = "mux_pane"still falls back to the PTY overlay under herdr, and now says so. A macro needs the new pane to run a command, andherdr pane splithas no trailing-command form, so running one takes a second call with the pane id thatpane splitprints back. -
A worktree note can be a checklist (#557).
Ctrl+tin the note editor ticks the box on the line and spawns one when the line has none, from anywhere on the line;Ctrl+umakes the line a list item or takes the marker back off it;Entercontinues the list and ends it on an empty item, the way every Markdown editor does. Both chords are Ctrl-modified because an unmodified printable is text in that modal, and which chord is left over is tmux’s call:Ctrl+bis its prefix, andCtrl+h/Ctrl+j/Ctrl+k/Ctrl+lare the vim-tmux-navigator pane set that tmux forwards only to a pane running vim.Ticking used to mean arrowing onto the right column and retyping a character by hand, which is what a note becomes after a day: “what to check before opening the PR” is a list you tick off.
-
A vim normal mode for the note editor (#557).
Nopens in normal mode:hjkl,w/b/eand theirW/B/E,0/^/$,gg/G,x,dd, andi/I/a/A/o/Oto enter insert.oandOcarry the list marker the wayEnterdoes, the modal title carries aNORMAL/INSERTchip, and the modal’s own last row leads with the mode as a reverse-video badge (the treatment the statusbar context anchor already wears) before listing the keys that mode takes, as does the statusbar behind it. A list too long for the row is cut with a…rather than clipped at the frame, which reads as a list that ends there.The cost is
Esc, so it gets its own line: it no longer writes and closes on the first press. It leaves insert, and the second press saves.[tui] note_vim = falsebuys the single-press gesture back and returns the editor to the modeless one, where every printable is text, and it is a toggle in the Settings panel’s TUI tab like the other two TUI booleans. No counts, no registers, no undo: this is a scratch buffer, andCtrl+estill hands the file to the real vim. The verbs are hard-coded rather than bindable, so[tui.keys.modal.note]holds the same four verbs either way and an unmodified printable bound to one of them is still refused at load time. -
Merge a PR from the TUI (#551).
mfrom the worktree table merges the selected row’s linked PR;minside the PR / issue view merges the active tab’s. Both go through the delete flow’s confirmation, and it is the same modal: same layout, same countdown, same spinner while it runs, same buttons hidden mid-flight. Its summary names the PR,head → base, the resolved method and what it does to the history, and the CI rollup.mcycles the method from inside it, and a failure keeps the modal up with the forge’s own message.The check state is shown rather than enforced: a forge refuses a merge for reasons gwm does not model, and its own error says which. The source branch is never deleted: neither backend is ever asked to.
The method comes from the new
merge_methodkey and defaults tomerge, the least destructive of the three:merge_method = "merge" # or "squash", "rebase" -
A link can open in a terminal browser instead of leaving the terminal (#590). Every URL the TUI opens went to the system browser: the browse-links menu (
B), the open-menu Issue and PR picks, a row in the rich PR/issue view, a CI check’s details URL,.for the docs. On a tiling setup that means losing the workspace gwm is sitting in. The new[tui] terminal_browsernames a command that renders the page in the terminal instead:[tui]terminal_browser = "w3m {url}" # or lynx / carbonyl / browshThe
{url}placeholder is optional: a bare"w3m"gets the URL appended as its last argument, which all four of those tools take anyway.It is only consulted when a multiplexer is detected. A terminal browser with nowhere to put it is worse than the system browser, so
$TMUX/$ZELLIJ/$HERDR_ENVgate it, and the page opens in a new pane or tab beside gwm, at the level[tui] mux_open_inandmux_pane_directionalready set fortando. Where the container takes no command (herdr, a zellij tab, anyworkspace) it runs in the PTY overlay instead, so the browser still renders in the terminal, and the status bar names the backend that refused a pane. Anywhere else, including a browser that is not on$PATH, the system browser answers as it always has, with the reason on the status bar rather than silently.A browser that places itself is launched rather than hosted, via the companion
[tui] terminal_browser_open_in:[tui]terminal_browser = "terminal-browser open {url} --split right"terminal_browser_open_in = "detached" # default "overlay"Both shapes above host the browser, which assumes it draws inside the TTY it is handed. That holds for
w3mandlynxand fails for one that renders through the terminal’s image protocol: it positions against the real window, so in the PTY overlay it paints over the top-left corner of the screen whatever rect gwm passes, and in a gwm pane it splits twice because it splits on its own."detached"launches the command and stops there. The two gates stay in front of it: no multiplexer still means the system browser, since placing itself means asking a multiplexer for a pane, and a missing binary still falls back. An unknown value errors at load.Unset is the default and is exactly the behaviour up to 1.9, on every platform. The key is also editable in the Settings panel under the TUI tab (
4), where blanking it turns the feature back off.The URL is always one argument: the template is tokenised before the placeholder is substituted, so
w3m {url}andw3m "{url}"are the same command and a URL’s?,&and#cannot become shell syntax. Only absolutehttp/httpsURLs are passed on. -
The Commits and Working Tree overlays say which worktree they are showing (#629). Both full-size listings painted their snapshot and nothing else: a commit graph that could be any branch’s, a file tree that could be any worktree’s. The modal title could not carry it either, being centred and therefore clipped from the left, and the commit overlay already spends its title on the row count.
Each now carries a fixed row above its body: the branch for the commit listing, the worktree name and its path for the working tree. It is its own rect above the scroll region, not the first line of the listing, so it stays put while the body scrolls, and the Working Tree overlay renders it in its loading arm too, so the listing lands exactly where the loader was instead of jumping a line when the worker returns.
The row resolves from the path the overlay pinned when it opened, not from the live selection: the auto-refresh moves the selection while an overlay is up, so the cursor can point at a different worktree than the rows on screen. It is an in-memory lookup, so the render path still shells out to nothing.
Changed
Section titled “Changed”-
The published crate no longer carries the capture binaries (#581).
cargo packageon 1.8.0 measured 9.8 MiB compressed against the 10 MiB crates.io limit, anddocs/was 9.2 MiB of the 14.7 MiB it packaged, nearly all of it captures that build nothing. Rendering those captures at 2x needed that room back. Only the images leave: they are 18.1 MiB of the tree against 1.3 MiB for the markdown anddocs/schema, and droppingdocs/wholesale would strandcontract_tests.rsanddocs_frontmatter_tests.rsin the package reading a tree that is no longer there, socargo testfrom a downloaded crate would fail.exclude = ["docs/**/*.png", "docs/**/*.gif", "tests/docs_assets_tests.rs"]brings the manifest to 2.2 MiB and keeps both suites runnable. crates.io rewrites relative image links in a README againstrepository, so the crate page still showsdemo.gif. -
Modals follow
[tui] layoutinstead of always being bordered (#594).compacthas been the default layout since #545, and every surface honoured it but the overlays, which kept their rounded box whatever the config said. They now spend the same chrome the panes do:- the title rides a filled band on the frame’s first row, the same band a compact pane’s header wears, mixed from the modal’s own role so a delete or a merge confirmation keeps its danger colour and the two worktree forms keep their green;
- no rules on any side, top or bottom included;
- the row every modal already spends on its key hints is painted as a
quiet
section_bgfooter band. It is a ground under a row that was already there, not an extra one, so nothing moved to make room for it. A bordered modal’s bottom-rule counter (the Working Tree’s per-category counts) rides the right of that band; - a blank row at each end of the content, so nothing sits flush against a band. The boxed layout’s interior padding already gave that, and the four full-size overlays plus the note editor gained the one above their hints under both layouts.
That is two rows and four columns back per overlay, which is what the layout was asked for in the first place: modals are the surfaces most likely to overflow a short terminal.
A rule around a panel floating over content is worth something, and what replaces it is the ground: while a compact modal is up, everything behind it is darkened. The colours are mixed toward black rather than only dimmed, because
DIMreaches the foreground alone and a pane’s header band sits directly above a full-size overlay’s own band. A palette with no components to mix, an ANSI colour name or a 256-palette index, keepsDIMby itself.layout = "bordered"is the opt-out and is untouched, rules, padding, sizes and undimmed background alike. -
candCnow mean the same thing in both panes, which moved three bindings (#593).copens the commit listing andCthe CI checks, in the worktrees pane and in the status pane alike. A key that changes meaning under the focus is a key you have to think about, so:Action Was Now commits(new) cci_checksC, plus a contextualcon the status paneCeverywhereedit_worktree(rename)ceexit_to_worktreeeEThe contextual routing from #436, which existed to give the status pane its own
cfor the checks, is gone with it, and the PR line’s CI badge no longer changes between[c]and[C]under the focus. Existing[tui.keys]overrides are untouched; only the defaults moved. -
The rich PR / issue view (
I) had its design pass (#551). It was built to get the data on screen and had never been laid out; the compact layout of 1.8 made its own density the next thing that read as unpolished. Six things changed:- The issue and the PR are two tabs, switched with
Tab. The view still opens on the PR, which left the issue unreachable from a worktree in review. A PR landing while the view is open still replaces an issue that was only standing in for it, and does not replace one you tabbed to. - Bodies render as Markdown rather than as their source. Headings, emphasis, inline code, fenced blocks, lists, task lists, block quotes, GitHub alerts, links by their text, and HTML comments not shown at all.
- Nothing is capped. The view scrolls, so the window is the terminal
and the row count costs only the rows. Descriptions, reviews and the
whole conversation render in full; a
… N morerow now only reports what the fetch itself did not return. - The metadata block wears the Status pane’s colours, resolved through the pane’s own helpers rather than a second set of rules.
- A width policy of its own, 80% of the terminal capped at 120 columns against the shared overlay’s 62% capped at 88. That ceiling was chosen for the clean report, whose rows stop earning columns; prose does not. A label-less row also spans the whole inner width now, which the view was paying for twice.
- Code and diff lines are kept whole and scroll sideways with
h/l. In YAML or Python the indentation is the program, and a wrapped+line’s continuation carries no sigil and reads as context. ycopies the active tab’s URL,Yits description.- Pager motions:
D/Umove half a window,g/Gjump to the ends, andcopens the same PR’s CI checks without leaving the view. - A modal opened from the view closes back to it, on the tab that was
being read. The CI list and the merge confirmation are both reached from
inside it, and landing on the worktree table meant re-selecting the row
and pressing
Iagain to carry on reading. Opened from the table, both still close to the table.
- The issue and the PR are two tabs, switched with
-
A tmux or zellij split now opens to the right by default (#589). Up to 1.9 a split carried no direction at all, so each backend answered for itself:
tmux split-windowfell back to-vand stacked the pane,zellij action new-panetook “the biggest available space”, and herdr went right because gwm hardcoded it. All three now pass a direction, and it defaults toright: it is what the--splithelp has promised since it shipped (“a horizontal split of the current pane”), and the half that is actually free on a wide screen. Set[tui] mux_pane_direction = "down"to get the old tmux behaviour back.
-
demo.gifrecords the demo again, and shows the note marker (#601). The recording was meant to be reshot for one reason, the note column’s marker moving to thenf-oct-markdownglyph in #595. Watching the reshoot frame by frame turned up a bigger one: the tape had been broken since #557 shipped vim normal mode on by default.Nopens the note in NORMAL, so the prose the tape types was read as normal-mode verbs (Ropened replace and ate the first characters), and closing takes twoEscwhere the tape had one. The modal therefore never closed, and every later keystroke, the worktree it creates, the filter, the delete, landed in the note instead of the list: roughly the last two thirds of the recording showed something other than what it claims. The tape now enters insert first and closes with both presses, with the reason written above the sequence so the next reader does not rediscover it. -
The doc captures are rendered at 2x and stop blurring on the site (#581). Every capture was generated at terminal scale, so the PNG that shipped was exactly as wide as the terminal it photographed. The docs site paints a capture wider than that (
hero.pngis 1000px and measured 1230 CSS px in a 2560px viewport) and a HiDPI display doubles the demand again, so the browser was upscaling text before anyone read it. Nothing could catch it: the reference resolved, the build was green, the text was simply soft.vhs 0.11 has no
Set Scale, so density comes from doublingSet FontSize: 30 instead of 15, with the geometry following. Doubling the geometry exactly is the trap. It moves the terminal grid rather than leaving it alone, and a capture that reframes is not the same capture with more pixels. Measured with atput colstape:narrowis 81 columns at 800px and gains one at 1600px, while 1580px lands back on 81. So the widths are trimmed off the doubling, not read from it:heroships at 1980px and holds its 103x31,narrowat 1580px and holds its 81x31, which keeps it under the 120 that would flip it to the side-by-side layout it exists to contrast.All 29 captures were regenerated against a binary built on current
dev, the two thatgenerate.shskips (demo.tape,github-linking.tape) by hand. Two guards intests/docs_assets_tests.rshold the line: a 1580px floor, read out of each shipped file’s own header rather than trusted from the tape, andSet FontSize 30on every tape, since a new tape copied from an old one is how 1x comes back. The floor is 1580 and not the 1600 a straight doubling suggests, for the same reason the widths are.Half of the symptom is not fixable here. The site sets
width: 100%on content images, which stretches a capture past its own pixels whatever its density; that is tracked as kbrdn-docs#76. -
The rich Issue/PR view keeps its inline comments through a relist (#619). With the rich PR view open on a PR whose review threads had landed, a worktree relist (the periodic
tui.auto_refresh_secsone, or an explicitf) emptied the thread cache, and the next PR result to land rebuilt the open view against an empty one. The inline comments disappeared from under the reader, and only closing and reopening the view, or refreshing it withf, brought them back.The view survives that same expiry for the PR itself because it renders its own snapshot of it; the threads were the one thing it read live from the cache. A relist now keeps the threads of the PR it is showing, which are as authoritative as the PR they hang from, and expires everyone else’s as before. Re-requesting them on each tick instead would be fresher and worse: the section collapses to a loading line for the round trip, once per refresh interval, taking the reader’s place in the comments with it. Refreshing the view with
fstill asks for them again. -
gwm create --issuesanitises every value it echoes (#617). The branch type was printed raw on the summary line, and it reaches that line from two untrusted places:--typeis argv, which clap hands through with its control bytes intact, and a type derived from the issue’s labels is a key of[issue_template.by_type], a string out of an unvetted repo’s.gwm.toml. The echo happens beforeBranchSpec::new_with_typesgets to reject the type, so validation was not the guard. All three values on those lines now go throughsanitise_for_terminalrather than the diagnostic variant, which deliberately lets a newline through and would break a line these values are spliced into. -
The CLI no longer runs on the 1 MiB stack Windows gives a process’s main thread (#617).
Cli::parsealone was sitting at that ceiling in a debug build: clap’s derive expands oneCommandbuilder per subcommand and per argument into a single frame, and every///incli.rsis along_helpstring inside it. Adding three arguments togwm createtook the binary from “survives a 1024 KiB stack, dies at 512” to “dies at 1024, survives 2048”, so everygwm.exeinvocation aborted withSTATUS_STACK_OVERFLOWwhile macOS and Linux, which give main 8 MiB, stayed green.mainnow does nothing but spawn a worker with a 16 MiB stack and relay its exit status. Trimming doc comments back under the ceiling would have bought one release and handed the same failure to the next argument anyone adds; choosing the stack takes the ceiling out of the picture, and costs address space rather than memory, since a thread stack is reserved up front and committed page by page as it is used.tests/main_stack_tests.rsprobes from a thread the size of the one Windows gives main, so the guard cannot pass vacuously on a Unix runner. -
The selected worktree keeps the GitHub context that was fetched for it (#597). Standing on any row but the one the TUI opened on,
C/csaid “no CI checks to show: link a PR and fetch (F) first” for a worktree whose PR was linked and whose checks had already been fetched, and the rich Issue/PR view (I) refused for the same reason.fdid not help: it refreshes the worktree list, not the GitHub layer, so only anFon that exact row filled the state back in.gwm was throwing away its own prefetch. It fetches every linked issue and PR at startup and on every relist, but the link re-read that runs on each selection change flushed the whole result cache and dropped any in-flight
ghworker with it, so the prefetch died on the firstj. That flush was a leftover: the cache has been keyed by number since #138, so it cannot serve one row’s status for another, and a row that never fetched still reads as unfetched. It is now dropped only when the origin actually moves between two forge instances, which is the one case where a cached number means something else.The two verbs also stopped reading “nobody asked yet” as “nothing to show”. A linked PR that has never been fetched is now fetched on the spot, on the same task spine, and reported as
fetching Pull request #61...; one already in flight says the same without starting a second call; one whose probe failed shows whatghsaid instead of pointing at a fetch that had already run; and a PR that is fetched with an empty rollup says its checks have not been reported rather than naming a link and a fetch that are both already done. Only a row with nothing linked still gets the link hint. Workspace mode gains the most: it skips the bulk prefetch by design, so before this the verbs there were fed by nothing at all.Freshness is unchanged: a relist still expires every fetched status, so
tui.auto_refresh_secs(60 by default) still bounds how stale one can be. That expiry moved ahead of the bulk prefetch’s early returns, which is what gives workspace mode the same bound rather than none. -
An overlay’s toggle key closes it whatever it is bound to (#613).
3,4andWeach close the overlay they open, but the guard doing it askedkey_matches_action, which reads a single stroke and only ran after the modal verbs had their turn. Two silent holes: a multi-stroke binding (working_tree = ["g w"]) could open the overlay and never shut it, and a binding the overlay’s own context already claimed (= ["j"]) opened it and then scrolled it. The toggle now resolves first, against that one action rather than the whole keymap, and it accumulates its chord, so a prefix stroke is consumed instead of firing a scroll verb on the way through.Each of the three overlays routes its keys through an
Appmethod now (the shape the create overlay has had since #217), because the ordering is the fix and amatchin the run loop cannot be tested.dstill cannot reach the delete confirm from behind an overlay: the toggle resolves against its one action, not the whole keymap. -
A compact pane’s header says where you are, and its name no longer dims when it is not (#605). In the default compact layout the header carried the focus signal twice, and both halves were weak. The text was repainted from
focustomuted- so a pane’s name, the thing you read to know which pane toTabinto, was rendered in the role reserved for deliberately secondary text the moment it went inactive, while the spans that already carry a colour (the filter/prompt, the Working Tree counts) did not follow, leaving one header line running two rules side by side. And the fill under it stepped fromsection_bgtoselection_bg, two tones that are adjacent by design (14 grey levels apart onclaude-dark) and that read as a permutation of grey rather than as a place.The two states now trade the same pair of roles instead of dimming one of them. An inactive header is
accenttext on thesection_bgband; the focused one is that band’s tone written on anaccentband, bold - the same dark-on-colour treatment the version chip and the footer’s context anchor already use.mutedappears in neither, the focused pane is findable without hunting, and the header no longer borrowsselection_bgfrom the cursor row.The band is
accentpulled down towardsection_bgrather thanaccentat full strength, which was too loud, and rather thanfocus- the border tone, which is more saturated and so does not fix the half of “too strong” that darkening does. It is mixed from the two roles it sits between rather than declared as a sixth background role, so a[theme]override of either keeps them in tune; a palette with nothing to mix - an ANSI name, whose value belongs to the terminal, or a 256-palette index, which is the default theme’s case - keepsaccentitself rather than falling back to a grey. How far it can be pulled down is bounded by the dark text written on it: the two keep the 3:1 WCAG asks of bold display text, which is pinned by a test.Spans that carry their own colour keep it on either band - the header style is patched onto them, not substituted - so a filter prompt or a per-category count still says what it says. The right-flushed counter follows the title, which bordered mode already does with the border colour. An inactive header is no longer bold, which is what makes the weight a signal.
Bordered mode is otherwise untouched: there the accent still paints the four rules and the title inside the top one.
-
A linked row with nothing fetched yet is white, not green and purple (#596). The table’s
I/Pmarker painted its two placeholder slots with a different status role each:cleangreen for the issue,lockedpurple for the PR. So one row said two different things about the same missing data, and both colours were on loan from a loaded state (cleanis an open issue and an open PR,lockedis a merged PR, a closed issue, and the locked-worktree badge). That is the state every linked row launches in, since nothing is fetched untilF. Both slots now takename, the one role in the marker that neither badge map can produce and the colour the empty slot beside them already uses. The glyph still tells the two apart:-is “no link”,●is “linked, not fetched yet”. -
The note column captions itself (#595). The column shipped with an empty header on the grounds that its marker is binary, which left the marker sitting under a blank caption immediately right of the two-slot
I/Pgroup, where it read as a third slot of that group rather than as its own column. It now carries the same glyph it marks rows with, and both moved from≡to thenf-oct-markdownglyph the Working Tree pane already paints on a.mdfile, since a note is one. The column stays conditional, so a user who never writes a note keeps the exact table they had before.