`setup_wsl` resolved the distro's login shell with a synchronous `wsl.exe`
call. The client waits for the daemon's `Spawn` reply
(`terminal::remote::spawn`), so on a cold WSL start — seconds, while the
distro boots — the entire window froze. Reported from a real session; the
`--cd`/`-d` unit tests never saw it because they never reach the probe,
and the live-PTY test only ever ran against an already-warm distro.
Caching per distro was not a fix: the first WSL pane after launch is
exactly when the distro is cold, so the freeze hit precisely the case the
cache could not cover.
Fold the decision into the one `wsl.exe` invocation we were always going
to make. The command is now `sh -c` over a `case` on `$SHELL` that execs
bash with our rcfile, or falls back to a plain login shell for a distro we
don't integrate. It cannot block, because there is no second invocation.
`$SHELL` rather than `getent passwd`: WSL populates it from the user's
passwd entry, so inside the distro it already is the login shell of
record — the same source `shell_kind` trusts on Unix. Written without a
variable assignment so the whole thing stays one `case`, robust to the
layers of quoting between the daemon and `sh`.
The rcfile is now written before the shell is known. That is a local write
into a throwaway dir the terminal already cleans up on drop, and paying it
unconditionally is what buys the decision being free.
Regression test names a distro that cannot exist and asserts setup still
succeeds — if anything asked the distro a question, it could not. A timing
bound would only have caught this on a cold machine, which is the same
blind spot that let it ship.
Removes `wsl_login_shell` and `inner_shell_kind`, both now unreachable.
736 tests pass, clippy warning count unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`wsl.exe` is a launcher, not a shell, so the integration has to reach
through it into the distro. Probe the distro's login shell, write the
matching rcfile on the Windows side, and pass its path in via `WSLENV`,
whose `/p` flag rewrites it to the distro's own view of the filesystem
(`C:\…` -> `/mnt/c/…`) — so the `/mnt` automount root, which is
configurable in `/etc/wsl.conf`, is never hardcoded.
The argv becomes `[<launch flags>] -- sh -c 'exec <shell> --rcfile "$RC" -i'`
rather than `-- <shell> --rcfile <path>`: the path only exists as an env
var *inside* the distro after translation, and `wsl.exe` execs its command
directly with no shell to expand it. The one-shot `sh` execs away at once.
No new shell code — the distro runs bash, so the existing snippet applies
verbatim. Only bash is wired up; zsh and fish inside a distro are
reachable the same way but each needs its own verification pass, and
declining leaves those panes launching bare, as every WSL pane did before.
Tag WSL panes with a new `RemoteKind::Wsl` so `TerminalView::local_cwd`
declines their cwd. This is the load-bearing half: the distro reports
`/home/me/proj`, which Windows reads not as invalid but as *drive-relative*,
resolving to `C:\home\me\proj`. Without the tag, the local git probe, path
completion, link resolution and cwd inheritance would all consume it — and
on a machine that happens to have such a directory, silently consume the
wrong one. The gate itself landed in #133; this adds the third kind to it.
Two consequences of that tag needed explicit handling, since nothing
matches exhaustively on `RemoteKind` and every miss would have been a
silent fall-through:
- the foreground-`ssh` poll cleared any context the probe didn't produce,
which would have blanked the WSL tag (and the pane's cwd with it) twice
a second. It now only replaces the kind it authors.
- the tab status dot and `active_ssh_pane` treated "has a RemoteContext"
as "is an SSH pane". Both now test the kind.
`Injection::force_non_login` is renamed `replaces_argv`: bash needed it
because `--rcfile` is ignored for login shells, WSL needs it because the
launch flags and command must be reordered around `--`. The mechanism was
always "these args replace rather than extend"; only the name was bash's.
Verified end-to-end on a real ConPTY into a real distro — the new test
asserts the full A/B/C/D cycle comes back through `wsl.exe`, which is the
only way to show `WSLENV` translation, `wsl.exe`'s argv passing and the
distro's own startup chain all survive together. It shares its harness
with the Git Bash test, including the two ConPTY behaviors that harness
encodes.
736 tests pass, clippy warning count unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups to the `local_cwd` gate.
The agent-reported cwd sat ahead of `local_cwd` in the git-status chain
and bypassed the gate entirely. A native-SSH pane keeps sentinel-sourced
agent state on purpose, so an agent running *on the remote host* reported
a remote path that won unconditionally and reached the local `git` — the
exact collision the gate exists to prevent, on its most likely trigger
(running `claude` in an SSH pane). Route the agent's report through the
same remote check.
Completion's fallback to `std::env::current_dir()` meant a remote pane
now offers *this* machine's filenames for insertion into a remote command
line, where before the remote path simply failed `read_dir` and produced
nothing. `complete` takes `Option<&Path>` so "no local filesystem" is an
explicit contract: command completion still runs, path and signature
sources are skipped.
`apply_remote_context` left `st.cwd` pointing into the namespace it just
left, so after `exit` from `ssh` a local shell without shell integration
kept serving the remote's last path to the local `git` probe. Clear it on
both sides of the boundary; `DaemonMsg::Cwd` has no cleared form, so the
client mirrors it off `RemoteContext`.
Finally, validate in `initial_working_directory`: whatever wins must be a
directory *here*. This bounds the whole class rather than one shell's
spelling — an unresolvable path now falls through to the next candidate
instead of failing the spawn with "The directory name is invalid".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two holes in the Git Bash support.
`shell_kind` matched any `bash.exe`, including
`C:\Windows\System32\bash.exe` — the WSL launcher, which exists on any
machine with WSL and normally precedes `Git\bin` on PATH. Injecting into
it is destructive rather than merely useless: `--rcfile` *replaces*
`~/.bashrc` instead of supplementing it, and the Windows path we pass
does not exist inside the distro, so the user silently loses aliases,
prompt, and PATH. Identify msys bash positively and fail closed — a bare
`bash`/`bash.exe` is declined too, since its PATH lookup is exactly what
we cannot predict. The cost of a false negative is only the absence of a
feature that did not exist before.
`pwd -W` is the identity for msys-only virtual mounts (`/proc`, `/dev`),
which have no Windows path at all, and the `[[ "$d" != /* ]]` test could
not tell a translated path from an untranslated one. Require a drive
letter and report nothing otherwise: the `$PWD` fallback would have
re-emitted the very msys path this branch exists to avoid, landing as
drive-relative `C:\proc` and failing the next spawn. Staying silent
leaves the daemon holding the last usable cwd.
The prior OSC 7 test asserted only that the marker was emitted, so the
payload shape went unchecked; it now round-trips through the daemon's own
`parse_osc7`, including the case the guard suppresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `defer_to_ime` early return sat above `keystroke_to_bytes`, and the
IME path terminates in `write_gap_text`, which writes raw UTF-8 with no
Kitty awareness. So under REPORT_ALL_KEYS_AS_ESC a plain `a` reached the
PTY as `a` instead of the `CSI 97;1;97u` the app negotiated — on macOS,
unconditionally. Before this branch it worked on the default ABC layout,
since the key reached the encoder whenever no IME source was active.
Fold the check into `defer_to_ime` so both routing sites agree: it now
declines under `report_all_keys`, and `prefers_ime_for_printable_keys`
gets the same gate (CJK composition and "escape every key" are mutually
exclusive by construction — an app that asks for the latter gets it).
Disambiguate-only sessions are untouched: `encode_kitty` already
declines unmodified text keys there, so the IME route is equivalent.
The existing Kitty tests call `keystroke_to_bytes` directly and so could
not see this. `defer_to_ime` now compiles under `test` on every platform
and has coverage for the routing rule itself, so all three CI targets
exercise it rather than the macOS runner alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`quote_range` paired quotes by parity, so English prose broke it: in
`it's a test, isn't it` the apostrophe in `it's` reads as an opener and
pairs with the one in `isn't`, and a double-click on either selects
`'s a test, isn'` instead of the stock `it's`.
The existing comment anticipated this and claimed a missing match would
fall through to `None`, but that only holds when the line has a single
apostrophe — prose usually has two. This path also returns before the
`extends` guard, so there was no safety net.
Exclude contraction apostrophes (alphanumeric on both sides) throughout:
clicking one falls through to the stock word, and they count neither
toward the parity nor as a candidate match. `"` and `` ` `` are
unaffected — they don't occur inside words. Genuine possessives like
`the 'dogs' bark` still pair, since a delimiter always has a non-word
char or a line edge on one side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon is spawned detached with no console of its own, so a
ProxyCommand launched from it (`ssh -W`, `connect.exe`, `cloudflared`)
had Windows allocate one — not a flash but a black window that stayed
up for the whole session.
`hide_console` takes `std::process::Command`; this site builds a
`tokio::process::Command`, which is a distinct type with its own
`creation_flags`. Add `hide_console_tokio` alongside it so the module
comment's claim that every non-PTY Command goes through this file holds
again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A remote pane's OSC 7 reports a path in the remote's namespace. The
git-status probe, path completion, link resolution, the git-diff and
worktree shell-outs, session persistence and cwd inheritance all took it
as a local path.
Nothing gated this. `refresh_git_status`'s doc claimed remote panes have
no cwd, but that holds only *before* the remote shell's OSC 7 arrives —
after it, a native-SSH pane's remote path was fed to a local `git`, with
only `Path::exists()` incidentally saving it. That guard is weakest
exactly where it matters: on Windows `/home/me/proj` is not an invalid
path but a drive-relative one resolving to `C:\home\me\proj`, so a
machine that has such a directory would report an unrelated repo's branch
and diff as the pane's own. Correctness must not rest on that collision
never happening.
Add `TerminalView::local_cwd()` — the pane's cwd only when the pane is
not remote — and route every local filesystem/Command consumer through
it. Cwd inheritance for new tabs and splits is gated too: an inherited
cwd wins over every fallback in `initial_working_directory`, so a remote
path reached the spawn as a Win32 working directory.
Deliberately unchanged: `tab_cwd`, so "Copy Working Directory" still
copies a remote pane's remote path, which is what it is for.
Not fixed here: on a remote pane, path completion falls back to tty7's
own directory and link resolution stops matching relative paths. Both
degrade rather than mislead now; sourcing either from the remote is a
separate piece of work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Git Bash's $PWD is an msys path (`/c/Users/x`, and `/tmp` for mounts with
no drive at all). The daemon runs Windows-side, where `/c/Users/x` is not
absolute but drive-relative, so it resolved to a bogus `C:\c\Users\x`:
`strip_uri_drive_slash` only un-prefixes the `/C:/…` shape.
That silently disabled the git-status probe and path completion in every
Git Bash pane, and broke split/new-tab outright — an inherited cwd wins
over every fallback in `initial_working_directory`, so the next shell was
spawned with a working directory that does not exist. Worse than either,
`/c/Users/x` is a *valid* drive-relative path, so a machine that happens
to have `C:\c\...` would have shown an unrelated repo's status as the
pane's own.
Use `pwd -W`, msys's own translation to the real Windows path, which also
maps msys-only mounts correctly (`/tmp` -> `AppData/Local/Temp`). It has
no leading slash, so add one for the file: URI shape. Falls back to $PWD
if it fails or returns empty, and the branch is taken once at install
time rather than on every prompt.
The live-PTY test now round-trips what Git Bash actually emits through
the daemon's own `parse_osc7` and asserts the path exists, so the two
halves are verified to agree rather than checked in isolation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gpui derives `key_char` by running the event's virtual keycode back through the
current layout; it never reads the event's Unicode payload. That is fine for a
physical keyboard, but remote-control apps synthesize keystrokes as
`CGEventCreateKeyboardEvent(src, 0, ...)` + `CGEventKeyboardSetUnicodeString()`,
putting the real text only in the payload. Keycode 0 is `a`, so every character
typed from a phone arrived in the terminal as `a`.
gpui does divert printable keys to the input context, but only while a composing
input source is active (`is_ime_input_source_active`), so the bug appeared and
vanished depending on the selected input method — and the plain ABC layout, the
macOS default, always lost the text.
Make the IME the single delivery path for text on macOS: `on_key_down` declines
plain printable keys without consuming them, so gpui falls through to
`handleEvent:` and the Unicode payload survives into `commit_text`. Chords are
excluded — Ctrl/Cmd/Fn belong to the encoders, Option to the Meta policy — and a
pending multi-key chord still wins, matching `prefers_ime_for_printable_keys`.
Answer `apple_press_and_hold_enabled()` with false as well. `on_key_down` used to
consume printable keys before gpui consulted it; now that gpui reaches its
held-key branch, false is what keeps auto-repeat instead of handing the key to
the accent palette — a terminal wants `jjj`, not `ĵ`.
Tests that assert on text input now go through a `type_char` helper that follows
the platform: `commit_text` on macOS, `on_key_down` elsewhere. Building a
`KeyDownEvent` by hand exercised a path macOS no longer takes. Production
semantics are unchanged: `input_active()` is already false at a shell vi-mode
prompt and `write_gap_text` has its own `shell_vi_prompt()` branch.
Linux is unaffected and still uses the `key_char` path, since gpui's IBus
integration does not commit plain ASCII back through `replace_text_in_range`.
The three native window controls are 34px tiles, so consecutive glyph
centres sit 34px apart. The overflow ... was inset by `pr_3` (12px),
putting its centre `15 + 12 + 17` = 44px from the minimise glyph — a
10px overshoot that read as the button drifting away from the group
rather than as deliberate separation.
`pr_1` (4px) lands it at 36px. Measured on a 1.5x display: 54.5 physical
px between ... and minimise vs 51px between the window controls, i.e.
within ~2 logical px of the native rhythm, while keeping just enough
slack that it does not read as a fourth window control.
macOS is unaffected (controls are on the left there; `pr_2` still holds).
The `strip_w` 114px reserve is untouched -- this padding lives inside the
strip box, so shrinking it only shifts the ... right and hands the chip
row 8px more.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PowerShell was the only Windows shell that got OSC 133 integration. Git
Bash was excluded by two unrelated gates, neither deliberate:
`shell_kind` stripped `.exe` only for PowerShell, so the `bash.exe` that
`core::shells::find_git_bash` resolves matched nothing — even though the
comment right above it reasons about exactly that suffix. Strip it for
every shell instead.
The Git Bash dropdown row also ships `-i -l`, which tripped
`has_custom_args` and made `setup` decline bash outright. But that guard
exists to protect args the *user* configured; these are tty7's own, from
`detect_shells`. `ShellSpec` now carries who authored its args, so
integration may respell tty7's (`--rcfile … -i` plus the replayed
login-file chain means the same thing) while still leaving the user's
alone. The `-i -l` stay as the fallback for when integration doesn't
apply or fails to set up.
One msys2 detail: the rcfile path is now spelled with forward slashes,
which its runtime accepts just as readily and which carry no second
meaning in the bash string contexts the path can reach.
Verified end to end, not just by construction: a new live-PTY test
spawns the real Git Bash through the real `setup` output and asserts the
full A/B/C/D cycle plus OSC 7 come back. It skips when Git for Windows
isn't installed. Getting it green surfaced two ConPTY-isms worth
recording — the master doesn't reliably EOF when the child exits, and
closing its input side raises a console control event that kills the
shell with STATUS_CONTROL_C_EXIT — both noted at the call sites.
cmd and WSL stay unintegrated, now documented as decisions rather than
gaps: cmd's only hook is PROMPT, which cannot emit C or D, and since
only C clears `at_prompt` an A/B-only shell would leave the line editor
holding the keyboard for the whole of every command. WSL would need
per-distro shell detection and WSLENV path translation to reach the
shell that actually runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tty7 is a GUI process with no console, so launching a console-subsystem
child makes Windows allocate one for it — a black window that pops up
and vanishes. The git status probe is the worst offender: it shells out
four times (rev-parse --show-toplevel, branch name, --git-dir /
--git-common-dir, diff --numstat) and runs on every pane cwd change,
command end, and agent-turn end. Opening a shell in a repo flashed four
windows.
Add core::proc::hide_console — CREATE_NO_WINDOW on Windows, a no-op on
Unix so callers stay cfg-free — and route every non-PTY shell-out
through it: the status probe, worktree's git, the diff-review git calls,
the codex CLI, and shells.rs's WSL probe (which had its own copy of the
constant, now one source of truth).
PTY children are out of scope; daemon::spawn already passes its own
flags for those.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<` and `>` are comparison and redirection operators at least as often as
they are delimiters, and the bracket path returns before the `extends`
guard that keeps every other smart candidate additive — so a bad match
there has no safety net and can select half a line.
Accept an angle pair only when neither delimiter is followed (resp.
preceded) by whitespace. Measured against the repo's own source plus a
corpus of shell commands, logs and diagnostics, that keeps every real
delimiter — `Vec<String>`, `<div class="row">`, `<command>`,
`<user@host>`, `<info>` — and drops the operator matches
(`a < b > c`, `x <= 0 || y > 9`, `WHERE a < 10 AND b > 20`).
Redirections need no handling: `2>&1` and `cmd > out` have no partner to
match, so the scan already failed on them. Other pairs are unaffected —
`( a )` is a legitimate subshell and `[ 1 ]` a legitimate index.
The existing tests all drove the pure string helpers; the grid path — where
the index arithmetic is actually hard — had none. Add tests that feed a real
`Term` through the VT parser and assert on the resolved selection:
- OSC 8 hyperlink runs, including one spilling across a soft wrap in both
directions, and a cell outside the run not picking the link up
- a URL split by a soft wrap, clicked from the head (joins forwards) and from
the continuation row (joins backwards)
- wide CJK glyphs, where clicking the glyph cell and its trailing spacer must
resolve to the same word, plus a glyph pushed onto the next row by a
leading spacer
- a click past the last column resolving to no range
Verified by mutation: breaking the trailing-spacer offset, the hyperlink
wrap walk, or the logical-line backward join each turns one of these red.
jieba's dictionary costs ~55 MB resident and ~130 ms to build, and it was
built eagerly on every terminal-view creation regardless of the
`smart_select` setting or whether the user ever selects CJK text.
macOS already ships a Chinese lexicon in CFStringTokenizer that matches
jieba on most prose, is locale-independent (identical output for current /
NULL / zh_CN / en_US), and segments Japanese and Korean properly where
jieba shreds them into single characters. Make the OS tokenizer the
primary path and keep jieba only as the fallback for platforms with no
such API:
- gate the dependency behind `cfg(not(target_os = "macos"))` so the
embedded dictionary isn't even linked into the macOS build
- never warm eagerly; the first CJK double-click kicks the build off in
the background and settles for the unsegmented run, so the UI thread
never blocks on it
- skip jieba for runs containing kana or hangul, where selecting the
whole run beats per-character tokens
Also honor `Config::smart_select` in the prompt's command editor, which
did bracket pairing, CJK segmentation and mixed-script narrowing even
with the Settings toggle off.
- Double-click expands to the whole URL, email, file path, sci-notation
number, identifier chain, OSC 8 hyperlink run, or matching bracket/quote
pair containing the clicked word. Candidates only ever grow the plain
word selection, so nothing regresses below the stock word behavior.
- Chinese segments with jieba's dictionary on all platforms; Kana/Hangul
use CFStringTokenizer on macOS. The table builds lazily on a background
thread so the first double-click never pays the cost.
- Latin words glued to CJK text narrow to the clicked script's sub-run
instead of selecting the mixed blob.
- Bracket pairs (ASCII and full-width) and symmetric quotes (parity
matched) select through their match, in the grid and prompt editor alike.
- Shift+click extends the existing grid selection instead of restarting.
- Word separators are configurable (word_separators, shared by grid and
prompt editor); new 'Smart selection' toggle in Settings > Terminal.
Claude Code's EnterWorktree chdirs the agent without any shell cd, so the
sidebar's branch/diff line could not see the move on platforms without the
proc-cwd fallback. Forward the cwd field every Claude Code hook payload
already carries through the OSC 777 sentinel, keep it on the pane's agent
session state, and let it take precedence over the proc probe for the git
line. The claim is released on session-end (and when the agent leaves the
foreground), so an exited agent falls back to the pane's real directory.
Every chip permanently reserved a 6px gap + 20px slot for a close
button that is invisible until hover, which read as a hole between an
ellipsized label and the chip edge, and wasted label width on crowded
strips. Drop the in-flow slot: the label now runs the full chip width,
and on hover the ✕ floats over its right edge on a solid backing in
the chip's own fill with a short gradient run-in, so covered text
fades out and nothing reflows.
The ⌘N hint badge keeps an in-flow slot: it also shows on unhovered
inactive chips, which are transparent over the window background, so
an overlay has no solid colour to back onto.
The install/uninstall outcome note repeated the agent name and full
target path inside the row's shrink-proof control column; a long path
inflated the column past the 640px content cap and crushed the label
column (min_w_0) to zero width, wrapping the title one character per
line.
- Terse action summaries: "Installed" / "Removed" — the row already
shows the agent and target path. Codex keeps the feature-flag
failure advice; the launch-refresh log line carries the agent name
and target path itself now.
- Control column gets items_end so status, buttons, and note share
one right edge across all agent rows.
- Note is width-capped (max_w_80) and wraps, so long error text can
never expand the column again.
- Outdated status text shortened to one word; the Update button says
what to do.
Replace the orange window-and-cursor identity with the "Duo" mark: two
offset session panes (mint #3FDD8C behind, ink #17171A in front) with a
prompt chevron, on a cream tile following the Big Sur icon grid.
- app-icon.svg is the master; app-icon.png, tty7.icns and favicon.ico
are re-rendered from it (rsvg-convert + iconutil + PIL)
- logo.svg/logo.png/logo@256.png carry the tile-less transparent mark
- tray.svg redrawn as the same mark in template form: solid front pane
with the chevron masked out, back pane at partial alpha
- bare (non-bundled) macOS binaries now set the Dock icon at runtime
via NSApplication.setApplicationIconImage, so cargo dev/run shows the
logo instead of the generic executable icon
- README version badge and the social preview switch to the new brand
green; social preview copy re-aligned with the current README slogan
Command::spawn returns after the fork, possibly before the child has
exec'd sleep; until then /proc/<pid>/exe still points at the test
binary itself, so asserting the basename immediately is a race. This
intermittently failed the Linux CI job on main (runs 29549626571,
29560431777, 29563049775). Poll process_path for up to 5s and carry
the last-seen basename into the failure message.
The attention state left template mode to carry the amber badge, which
forced the glyph to a hardcoded mid-grey that was illegible on many menu
bar appearances. Drop the badge (and the grey recolor) on macOS: the icon
now stays a template glyph the system recolors for the bar, and agent
status lives in the tooltip and tray menu. Windows/Linux keep the amber
corner badge on their colored icon.
Add a "Sync with system" mode: when on, the active theme resolves from
two user-picked slots (theme_preset_light / theme_preset_dark) by the
current OS appearance and switches live when the OS mode flips.
- config: theme_follow_system + theme_preset_light/theme_preset_dark
- theme: effective_preset_id() resolver; release the native appearance
pin while following (it would blind the OS-appearance reads)
- app: observe_window_appearance re-applies the theme on OS flips;
set_preset writes the slot matching the current appearance while
following; explicit set_slot_preset for the Settings cards
- settings: sync switch + one card per slot; the picker panel aims at
the slot whose card opened it
Closes#107
Debug builds are console-subsystem (so println! logging stays visible
while developing the GUI), so every `tty7 agent-hook <agent> <event>`
process Claude Code spawns for a hook gets its own console window. At
end of turn several hooks fire (Stop, SessionEnd, PostToolUse, ...), so
a cluster of terminal windows flashes open and vanishes as each tiny
emitter runs and exits.
The emitter never uses its own console for I/O — stdin is piped and it
writes to the agent's console via AttachConsole — so free the throwaway
console the instant run_agent_hook starts. With no other process
attached, the console and its window are torn down before they can
paint. No-op in release (GUI subsystem, no console) and on Unix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidebar's repo grouping keyed on the git work-tree root
(rev-parse --show-toplevel), so every linked worktree of one repository
rendered as its own group. Probe the common git dir alongside and derive
a repository home — the main checkout's root — and group on that
instead. Branch and diff state stay cached per work tree, so each row's
branch line is unaffected.