Commit Graph
1083 Commits
Author SHA1 Message Date
Austin Spragginsandl0ng-ai 2cdc26f357 Wire hooks, resume and detection for Kimi Code CLI (#694)
* feat(agents): wire hooks, resume and detection for Kimi Code

Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.

Closes #693

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>

* fix(agents): harden the Kimi Code TOML hook merge and its resume flags

The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.

`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.

A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.

`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.

On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.

Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.

---------

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 09:51:05 +08:00
Wh1teandl0ng-ai e82a460794 fix(windows): normalize path separators before reveal and copy (#680)
* fix(windows): normalize path separators before reveal and copy

Open-folder (reveal_path) and copy-to-clipboard hand raw paths to gpui.
On Windows, mixed-separator paths (a forward-slash prefix joined with
backslash entries) reach reveal_path through two routes:

  - the shell's PWD — OSC 7 from Git Bash / MSYS bash reports `/`, and
    that string survives `Path::ancestors()` when the file tree walks up
    to find `.git`, so the file-tree root keeps the forward slashes
    while `read_dir` entries underneath it come back native (backslash);
  - `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which
    always prints `/` regardless of the calling shell. The SCM panel's
    `scm_repo_root` and the worktree creation in tty7-core both use it,
    so the root they hand downstream is `/`-prefixed and joins against
    backslash-joined entries to form `D:/code/tty7\skills`.

Windows' IShellFolder::ParseDisplayName rejects that with E_INVALIDARG
(0x80070057); reveal_path swallows the error (it only logs), so "open
folder" silently does nothing. The same mixed-separator paths also make
copy-to-clipboard produce strings the user has to retype before a shell
will accept them.

Add a native_separators helper in path_display and apply it to every
reveal_path call (file tree, scm panel, right-panel info cwd, sftp
downloads) and to every path copied to the clipboard.

* fix(windows): rewrite separators losslessly, and only for local paths

Review follow-ups on the reveal/copy separator fix.

`native_separators` went through `to_string_lossy`, so any path holding an
unpaired surrogate — legal in an NTFS name, not representable in a Rust
`str` — came back with `U+FFFD` in place of it, naming a different file.
Since `reveal_path` only logs its failures, that reads to the user as the
same silent no-op the fix is here to remove. It now maps over the path's
own UTF-16 code units and rebuilds with `OsString::from_wide`; `/` and `\`
are ASCII, so a unit equal to either is that character and never half of a
surrogate pair. Still `Cow::Borrowed` when there is no `/` to rewrite.

The three clipboard sites re-spelled remote paths too. File-tree "Copy
path" and the SCM panel's sat outside the locality guard their Reveal
neighbours sit behind, and the Info panel's cwd copied `effective_cwd`
while its Reveal checked `local_cwd` — so a Windows window onto a remote
Linux host copied `/home/u/src` as `\home\u\src`, which names nothing on
either machine. Each now shares one locality check with its Reveal.

Both "Copy working directory" entry points were missed entirely: the
app-menu action and the tab context menu each spelled the path their own
way. They now share `tab_cwd_text`, which applies the same rule.

Adds a Windows test that a lone surrogate survives the rewrite.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 09:47:26 +08:00
webdev edfea5b830 ci(macos): assert every Mach-O in the bundle is the arch it ships as (#687) (#692)
A macOS 26 user opened the Apple Silicon build and was told it "contains
Intel parts" (#687). Downloading what is actually published — v26.8.2,
v26.8.3 and the nightly after #605 — and reading every file's Mach-O
header says otherwise: the three binaries under Contents/MacOS are thin
arm64, nothing else in the bundle is Mach-O at all, and tty7-app's load
commands are all /System/Library/Frameworks and /usr/lib. The build is
right today. The likeliest reading of the warning is macOS pinning an
x86_64 program someone ran in a pane on tty7.app as the responsible
process — the same attribution bundle-macos.sh already documents for
TCC — and that belongs on the issue, not in this change.

What does belong here is that nothing would have caught it if the report
had been right. assert-macho.sh knows how to say "this is a 64-bit
Mach-O for <arch>, it links only what macOS ships, and it is signed", and
since #605 it has said it — about the standalone tty7-server asset, and
only that. It has never been pointed at anything inside the .app. A
helper built without --target on an Intel runner, a dylib dragged in
from /opt/homebrew, a universal binary from a toolchain that decided to
be helpful: each would have zipped, notarized and shipped, and the first
check would have been a user's Finder.

So check the bundle, in bundle-macos.sh, where release.yml and
nightly.yml both build it. After the signing block — assert-macho.sh
insists on a signature, and this way one pass covers Developer ID and
adhoc alike — and before the update zip and the DMG, so a bundle that
fails never becomes an artifact, and before the `mv` that dissolves
dist/tty7.app. First the binaries the script staged itself: tty7-app,
tty7 and, when it is packaged, tty7-updater, each through
assert-macho.sh at the full standard the server asset is held to. That
also leaves every shipped binary's load commands in the release log,
which is where the next report of this kind gets answered from. Then a
sweep of every file in the bundle: `file` says which are Mach-O of any
kind, `lipo -archs` names the slices in each, and the answer has to be
exactly the matrix arch. Any other name is the wrong build; two names is
a universal binary, which is what the report described. lipo judges
rather than a parse of `file`'s prose because Apple's `file` and
upstream libmagic word the arch differently and lipo's slice names do
not move. A sweep that finds fewer Mach-Os than the binaries staged
above fails as well, so a changed wording cannot quietly turn it into a
no-op.

On a Developer ID build this runs after notarization, which spends a few
minutes of notary time on a bundle that was never going to ship. Cheap
next to carrying a second copy of the block inside each signing branch.

Deliberately not a fix for what the reporter saw, if it is the
child-process attribution: no check at build time can speak for a binary
the user runs inside a pane. What it guarantees is narrower and worth
having — the bundle named arm64 contains nothing but arm64, and a release
where that stops being true fails on the runner.

Validated with bash -n and shellcheck, and by running the sweep — and
the whole script in its adhoc posture — on Linux against fake bundles
with file, lipo, otool, codesign, ditto and hdiutil stubbed: a clean
bundle passes and packages; a wrong-arch updater, a universal tty7-app,
a stray x86_64 dylib, an arm64e nested bundle and an empty bundle each
fail and name the file, and nothing is zipped after a failure. Not yet
run on a Mac; the next nightly is what answers that.
2026-08-20 09:30:42 +08:00
webdev 010457132f fix(terminal): shape a regional-indicator pair as the one flag it is (#686) (#691)
A flag such as 🇨🇳 is two Regional Indicator symbols, U+1F1E8 U+1F1F3.
Each is width 1 to unicode-width, so the grid gives each its own column
and no spacer: the pair already sits in exactly the two columns a flag
occupies. But `segment_row` sent each one to `Solo`, and a `Solo` is its
own `shape_line` call. The shaper never saw the two together, so it had
no chance to form the flag ligature, and each half came out as the
letter-in-a-box glyph an emoji face draws for a lone indicator. A `Solo`
also clips to two cells so a fallback face's advance has room, and that
box is two cells wide, so each half spilled into the next column as well.

Join a Regional Indicator and the one after it into a single two-cell
`Cluster`, the move a7835a0 made for SARA AM: two width-1 codepoints
that own a column each but are not atomic to the shaper. The check sits
ahead of the marks branch so a stray mark on either half (a VS16 on the
first, say) rides along in the cluster text instead of splitting the
pair — split, the other half paints alone as a box again.

`wide_base` stays false. With the ligature there is one glyph at
position zero and the pinning is moot. Without it — a font that lacks
the flag — the shaper returns two glyphs, and `force_width = cell_width`
pins the second into the second column, where it stays visible — the
two legible halves such a setup shows today. `wide_base: true` would
pin it at `2 × cell_width`, past the cluster's two-cell clip, and
swallow half the pair.

The edges fall out of the scan. An indicator in the last column has no
partner on its row and stays `Solo`; two halves of a flag on different
rows cannot be joined, and drawing them apart is the honest answer.
Three in a row pair greedily left to right, which is UAX #29's rule for
them. A style change between the halves keeps the cluster under the
first cell's style, as for SARA AM: a recoloured flag beats two boxes.
Selection, copy, cursor placement and reflow read the alacritty grid,
not `RowSeg`, and are untouched.

A grid-level test feeds 🇨🇳x through the emulator, `snapshot_cell` and
`segment_row`, pinning the premise that each indicator lands in one
plain column. A unicode-width or alacritty bump that changes that fails
there rather than misdrawing quietly.

Out of scope: skin-tone modifiers and ZWJ sequences. Those are a
grid-width problem — alacritty reserves four or six columns for them —
and nothing here touches them.
2026-08-20 08:56:09 +08:00
l0ng-ai 958d8b7442 feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)

Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.

They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.

`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.

- Half the terminal column by default; drag the divider, double-click it
  to cycle a third / half / two thirds, or use the palette commands. Two
  thirds deliberately runs past the half-window cap the side panels obey
  — only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
  the way they already reserve each other, and the column is derived from
  the *live* sidebar and panel widths rather than their floors, so a
  panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
  derived at render time and never stored, so widening re-docks on the
  next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
  file over the whole window in one tab while an agent keeps half of
  another is the normal case, and one global switch made each of those
  flip the other. A tab that has not been told reads `document_layout`
  from the config, which is what a fresh tab starts as — and which the
  menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
  bar's height of nothing above the column. The header is drawn into it,
  and behaves like the title bar it now sits in. With the detail panel
  closed the column reaches the window's right edge, so the header stops
  short of the trailing chrome through a width the tab strip's own
  reservation shares.
- The docked headers drop the traffic-light inset they never had to
  clear, and the diff header's branch name becomes the thing that yields
  so the view toggle and the close tile survive a column's width.

New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.

* fix(window): hold the docked column to widths the strip and the file agree on

Three defects in the document column, each with a guard test that fails
without its fix.

The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.

The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.

The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.

Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
2026-08-19 18:03:51 +08:00
l0ng-ai 7bcb91d8af fix(input): give the PTY back the Ctrl chords tty7 was eating (#684)
* fix(input): give the PTY back the Ctrl chords tty7 was eating

Follow-up to #682, which handed Ctrl+V to a full-screen program but left
three neighbouring holes of the same shape: a key the terminal answers
without the keymap ever seeing it.

The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]`
and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate
file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and
Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were
silent: gpui filters control characters out of `key_char` on all three
backends, so the text fallback had nothing to offer either. The table is
now the VT-220 one, each digit beside the punctuation that shares its
key, because every platform hands Ctrl+Shift+6 over as `^` with the
Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and
is spelled out with the reason. The twenty-six letters fold to `& 0x1f`.

`on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`,
left over from when tabs lived on ctrl-digits — they have been on
Alt+1..9 for a long time, so nothing claimed those chords and the block
only deleted keys. It also sat before `keystroke_to_bytes`, so not even
the kitty protocol got through it. Gone.

Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in
a `Terminal && !alt_screen` context, and the pane declares `alt_screen`
whenever a full-screen program owns the grid, so the behaviour #682
settled on is unchanged — paste at a prompt, SYN inside vim — while the
keymap can finally express it, the Keybindings page lists it, and the
user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell
everywhere, including readline's `quoted-insert`, and
`"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal
does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on
purpose, as a removable binding, for exactly this reason. The hardcoded
arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's
only paste chord and carries no control code to lose.

Last, the rule about control codes is one function instead of an
assertion buried in a test. `steals_a_control_code` plus a commented
`control_code_binding_allowed` back both the defaults test and a new
runtime warning, so a hand-edited config.json that takes EOF away from
every shell says so in the log. It warns rather than refuses: a chord
the user asked for by name is theirs to spend, the way the tmux preset
spends Ctrl+B. The invariant that still fails a build is that no
*default* spends one silently.

Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake
(`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a
rerun and on a clean tree). New: the whole VT-220 table asserted byte by
byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`,
`ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn`
drive the real keymap through `simulate_keystrokes` rather than calling
into the view; the keymap tests cover both escape hatches and the
context that withholds the binding. #682's two `handle_cmd_shortcut`
tests are replaced by those three, which assert the same behaviour at
the layer that now decides it; its end-to-end SYN test stands unchanged.
The gpui tests are unix-only, so CI is what runs them.

* fix(input): ask the grid, not the last frame, before Ctrl+V pastes

`AlternatePaste` carries `Terminal && !alt_screen`, but gpui matches a
keystroke against the frame it last painted, so the context outlives the
switch: a full-screen program that took the screen after that paint is
still "at a prompt" as far as the keymap is concerned, and the clipboard
lands in it. In vim's normal mode that runs as commands. The action now
re-reads the terminal mode and propagates instead, which hands the chord
to `on_key_down` and encodes it as the SYN the program is waiting for.

Also:

- the two escape-hatch assertions in
  `paste_ships_both_terminal_chords_off_macos_and_retires_together`
  built a one-entry binding table instead of the default one, so both
  passed without the hatch working — an emptied `AlternatePaste` cannot
  dispatch anything when it is the only entry in the table. They now
  apply the config line on top of the whole default table, and the
  `PasteText: ctrl-v` case checks both screens;
- the keyboard-shortcuts page claimed every other Ctrl chord reaches the
  program, which Ctrl+Tab and the Windows/Linux font-size chords do not;
- `steals_a_control_code` documents `@` and the backtick, which are in
  the set it walks but were not in the list beside it.
2026-08-19 17:38:28 +08:00
webdev f44b667639 fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681)
A restart on nightly 26.8.4 came back with every restored coding-agent
pane locked: Ctrl-Z printed its suspended message and never returned to
a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its
sibling — a restart after an upgrade that came back to an empty
workspace (#672) — was mostly closed by #554 and #579; what is left of it
is closed here too, because both are the same mistake, a restart's
rebuild reporting a success it did not have.

The locked panes are an `Attach` the client took on trust.
`attach_reply_prefix` reads far enough into the daemon's reply to tell an
`Error` frame from a replay, and a read that timed out with nothing in
the buffer fell through to the success branch: silence was read as "a
quiet pane". But a quiet pane is never silent. `attach_subscriber`
replays the pane's ring before the daemon reads a byte of our input, the
ring always holds a segment (`ReplayRing::new` starts with one and every
path that empties it puts one back), and every daemon build there has
been queues a `Size` and then a `Snapshot` first — a pane that has
printed nothing still answers with its geometry. So an `Attach` that
produced no bytes in the whole wait is one nobody is serving: a daemon
still mid-restart, or a socket some process holds open and will never
read. Taken for an attach, it made `spawn_shell_terminal_in` report
`restored = true`, the flag that skips the fresh spawn, the
restored-screen banner and the agent's `--resume`; and `write` threw
every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went
into that socket and vanished. Zero bytes is now the failure it is, and
the caller falls through to the path it already had for a pane that is
gone — a fresh shell under the old screen, with the resume typed.
Nothing changes on the wire.

That silence has a second reading, though, and only one of the two is
safe to act on. A daemon merely slow to serve — an execve handoff keeps
the listener and its backlog across the exec, and a fresh daemon adopts
its panes and seeds ids before it takes an Attach — would have served
the connection a moment later, and a fresh pane spawned over that live
one carries its history across (`history::carry` is written for a dead
pane) and starts the agent's resume against a session the old process
still holds. So a silent local Attach is confirmed before it is acted
on: the client asks the daemon `Version` on a fresh connection, which a
daemon answers before it touches any state. Answered, the daemon is up
and serving and the attach socket is one it will never serve — the
verdict stands. Unanswered too, nobody is serving yet; there is no third
path from a synchronous UI-thread call, so the attach still fails, but
the error and the log line say which silence it was rather than
claiming the pane is gone, since that is the line someone reads while
diagnosing an orphaned shell. Only local routes probe: a remote attach
already waits fifteen seconds and a second routed connection is a
second bridge process. The two-second local budget is unchanged — only
a silent connection ever pays it, and N silent panes hold the window
still for N of them.

`write` also stops swallowing the link refusing input. The first refusal
is logged once from the writing side, and unless the reader was retired
for a relink the pane is marked exited by the reader's own signal —
`exited_flag`, then the `Exit` event — since it is the same socket, only
found dead from the writing side first; the reader still raises its own
when it gets there, and the handler is idempotent. A retired link stays
quiet, for the reason the retired reader does. This is hardening for a
closed link, not the cure for #673 — a socket held open and never read
accepts writes into its buffer, and nothing here fires; the attach
change is what keeps that pane from existing.

The tabs that did not come back are the rebuild's licence outrunning
what it rebuilt. `tabs_from_session` drops any tab none of whose panes
would start; `settle_hydration` then marked the window `informed` as long
as *some* tab rebuilt, while the mirror it had just installed still
listed every tab the machine holds. The next `sync_window` ran at
`SyncScope::Full`, and `diff` at that scope emits `TabClose` for every
mirror tab not in `desired` — which the dropped tabs were not, and `held`
did not cover them: it only covers tabs on screen whose panes cannot be
represented. A partial rebuild deleted from the machine exactly the tabs
it had failed to rebuild, panes and all.

They are held now, rather than the licence withheld. `settle_rebuild`
records the wanted ids the window is not showing (`not_rebuilt`), and
`sync_window` carries them into `held`, whose contract in `diff` is
already "mirror tabs the window cannot speak for — close nothing, and
do not reorder around them". Withholding the licence would have been
the smaller change, and it is what the none-rebuilt case does, but it
takes `TabClose` away from the whole window for as long as the failure
stands, and a failure can stand across every restart (a tab whose shell
is no longer on the machine): every close the user made in the meantime
would come back on the next rebuild. Holding only the tabs that failed
leaves the window speaking for the ones it did put up. The set is
rewritten by the next rebuild and pruned against the mirror on every
sync, so a tab the machine lets go of stops being held. The none-rebuilt
guard is unchanged: a window that put nothing up still does not speak
for the workspace at all.

Two things about the held set said out loud. It reads the count of tabs
the tree asked for, not the ids it found: `tree_id` is not serialized,
so a session that reached this path from disk would name no ids, and
"no ids" must not read as "no tabs wanted" — that would hand the licence
to a window that rebuilt nothing, which is #672 again. And holding has a
cost with no retry: `diff` stops before its reorder pass and the
active-tab op whenever anything is held, and nothing rewrites the set
but the next rebuild — a re-prime and an `IfEmpty` hydrate on a
populated window never get there — so a tab that fails to rebuild holds
the window's tab order and active tab off the machine until the next
restart. That state was already reachable, since a pane whose remote
spawn failed stays connecting for the same span, held the same way; this
widens a standing hole rather than opening one, and a retry, or a way to
close a held tab from the window, is separate work.
2026-08-19 14:24:12 +08:00
webdev 3c95995e82 fix(input): hand Ctrl+V to a full-screen program on the alternate screen (#677) (#682)
In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where
the editor expected blockwise Visual mode. Windows Terminal (with its
ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS
was never affected, since Cmd+V is the paste chord there.

Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V
and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm
was the only unconditional one: Ctrl+C copies with a selection and
otherwise falls through to SIGINT, Ctrl+X falls through outside the
editor, but Ctrl+V always consumed, so SYN never reached the PTY --
`input.rs` had the byte, unreachably -- and an empty clipboard turned the
key into nothing at all. #270 set the rule that off macOS ctrl-<letter>
belongs to the terminal and anything sitting on one must fall through;
Ctrl+V was the exception that had escaped it.

The arm is now contextual like its neighbours. On the alternate screen
it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form
when the program has the kitty protocol on; off it Ctrl+V pastes exactly
as before, and Cmd+V on macOS is untouched. The alternate screen is the
gate rather than `input_active` because the editor is inactive whenever
shell integration is missing or the prompt editor is off, and gating on
that would take paste away from every such user; a program that has
switched screens is precisely the case reported. Inside such a program
paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of
which still stage a clipboard image for an agent.

The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the
hardcoded path whenever the keymap had nothing on them -- exactly the
state rebinding Paste leaves behind, which #271 promised would retire
Ctrl+Shift+V, but it went on pasting behind the user's back. Only
unshifted chords enter the block now; the shifted ones are the keymap's
alone.

The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as
though they were the bindings, next to a Select All row that already
showed its hint on macOS only. The three rows take the same treatment,
which is also what the command palette does.

Three view tests pin the split -- Ctrl+V falls through on the alternate
screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key
down on the alternate screen arrives at the PTY as SYN and nothing else
-- and the keymap's paste test now asserts that no default claims ctrl-v
in the Terminal context. The shortcuts reference notes where plain
Ctrl+V pastes and where it is the program's.

Fixes #677.
2026-08-18 23:28:01 +08:00
l0ng-ai ef333bf055 feat(terminal): make the wheel-zoom modifier configurable (#676)
Cmd-scroll zoomed the font with no way to move it or switch it off, so a
thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is
now a setting: the platform modifier by default, or Ctrl, Alt, or none.

Stored as the choice rather than the resolved key, so one config file
still means the same thing on a Mac and on a Linux box. Settings ->
Terminal -> Mouse carries the picker; off macOS Ctrl and the platform
modifier are the same key, so it shows one cell for them.
2026-08-18 12:16:17 +08:00
l0ng-ai 8b5aeb0077 Wire hooks, resume and fork for the CLI agents that support them (#666)
* feat(agents): hook, resume and fork support for nine more CLI agents

Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their
own settings.json the way Claude and Codex already do; Goose gets an
owned file under the Open Plugins layout it implements. Qwen is the only
one of them with a first-class PermissionRequest event, so it needs none
of the notification sniffing the others do -- and deliberately gets no
Notification hook at all, since that event fires for non-blocking alerts
too and would strand a pane on "waiting".

Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork`
is a real subcommand that is simply missing from `amp threads --help`.

Four detection and replay bugs turned up while checking each CLI:

- `python3 -m antigravity`, the documented way to trigger Python's own
  easter egg, was detected as a coding agent. The `antigravity` binary
  is the IDE's launcher shim anyway, in the shape of VS Code's `code`,
  not the terminal agent -- that one is `agy`.
- Amp lost every launch flag on resume. It names a thread with a
  positional argument, so the stale-flag list had nothing to drop and
  the generic bare-token check rejected the whole tail along with it.
- Gemini could be handed a command line it refuses to start from:
  `--session-id` and `--session-file` are mutually exclusive with
  `--resume` and were never stripped.
- Cursor's `--continue` was not stripped either, leaving it to collide
  with the injected `--resume <id>`.

Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now
come from first-party sources -- logo SVG fills and site CSS variables
-- rather than approximations. Qwen ships its real mark instead of the
generic bot glyph.

Hooks stay unwired for Aider (no lifecycle mechanism exists at all),
Cursor (its usable events gate permissions, and tty7's silent hook would
read as a failed check and auto-allow the command), Auggie (its command
field takes only script paths, needing generated wrappers, and the
constraint could not be verified without a billed run), and for Hermes,
Amp, Vibe and Antigravity, whose event sets are too thin to report a
blocked turn.

* fix(agents): strip every session-naming alias before replaying launch flags

Goose spells --session-id also as --id, --name as -n, and keeps a legacy
--path, all in one exclusive clap group; Qwen rejects --session-id next
to --resume; Vibe shortens --continue to -c. Any of these surviving a
replay broke the regenerated resume command. Qwen's --no-chat-recording
also persists nothing, so it now opts the pane out of resume and fork
like Auggie's --dont-save-session. The Qwen icon gains the 24x24
width/height every other agent mark carries.
2026-08-18 00:42:56 +08:00
l0ng-ai 9c2869a25f Trim the app's long-winded copy, add four dark themes (#663)
* refactor(i18n): drop the About page shell primer and trim the long copy

The About page carried a "How shells work" section explaining that shells
live in a background server. Nothing linked to it and the Updates and
Server sections below already say what happens to those shells, so it was
a paragraph of prose the page did not need. Remove it, its search index
entry, and its three L10nKeys.

Then cut the padding out of 48 strings across settings rows, dialogs and
notices. Two patterns accounted for most of it: the restart-server
dialogs stated "your shells keep running" up to four times each in
different words, and the config.json failure notices packed three
subordinate clauses into every sentence.

Nothing is dropped but repetition and clauses the reader can infer —
every consequence a dialog asks the user to weigh is still spelled out.
en, zh and ja stay in sync.

* feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night

Four more dark built-ins, taking the set from nine to thirteen. The docs
table and description are updated to match.

* fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count
2026-08-18 00:08:31 +08:00
l0ng-ai f4c31222a4 feat(cli): restart the server in place by default, keeping sessions (#669)
* feat(cli): restart the server in place by default, keeping sessions

`tty7 server restart` used to be stop + start, killing every pane, while
the GUI's Restart Server hands the daemon off to a new image via execve
and keeps everything running. Same verb, opposite side effects.

The CLI now probes the daemon for the handoff feature and asks it to
exec the tty7-server binary in place: same pid, same ptys, sessions
survive. Success is judged by the version endpoint answering with a new
per-process instance id, not by build strings, since the CLI and server
binaries can be on different versions.

A refused or stalled handoff leaves the daemon untouched and reports an
error suggesting `--hard` instead of silently killing sessions. The
stop + start path remains for `--hard` and for daemons that cannot
exec themselves (Windows, pre-handoff builds).

* fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended

After a taken handoff, the poll timing out does not mean the daemon died:
the singleton lock survives the exec, so a held seat is the new image
still coming up with every session aboard. Falling back to start() there
would grant it one second of grace and then reap it — bail with the seat
still held instead, and only start over a genuinely free seat.

The stop-and-start fallback (--hard, Windows, pre-handoff builds) now
reports that sessions ended instead of relaying start()'s plain report,
since the default restart's promise is sessions kept.
2026-08-17 22:59:54 +08:00
l0ng-ai 3dc63e2d87 fix(daemon): find and reap a seat-holding daemon that lost both its names (#671)
A daemon can survive quit-and-stop with its endpoint unlinked and its
pidfile gone while still holding the singleton seat (#667). Every later
launch then spawns a daemon that stands down against the lock and times
out red, and nothing on the machine can recover: stop answers "not
running", ensure_running reaps only through the pidfile, and flock
cannot say who the holder is.

Two roads led there, and both are closed:

- The reap identified a daemon by proc_pidpath alone, which fails
  outright for a live process whose binary was deleted — every nightly
  update replacing the installation. The identity check now falls back
  to the kernel's comm name (proc_name on macOS, /proc/pid/comm on
  Linux, both recorded at exec and immune to deletion), strips Linux's
  " (deleted)" marker, and — decisively — no longer deletes the
  pidfile of a live process it cannot identify: the record was the only
  handle left on the survivor.

- When the pidfile is gone entirely, the pid the claimant now writes
  into daemon.lock at claim time is the handle of last resort. The lock
  file is never deleted and holding the flock is the definition of
  being the server, so while the seat is held its content names the
  holder; stop() and the reap fall back to it, and a confirmed reap
  clears the record (only under a momentarily-free seat) so a stale
  number cannot outlive its process. Unix-only: the Windows seat is
  share_mode(0), unreadable while held.

Every road back now clears a stranded seat, not just the GUI's:
ensure_running's stale cleanup is factored into spawn::reap_stranded,
tty7 server start runs it too, and tty7 server stop no longer takes
"nobody answered" for "nothing to stop" when the seat is still held.
A short grace keeps the reap away from a daemon that is merely
mid-handoff or mid-startup — where health is an answered handshake,
never a bare connect: a wedged daemon's listener still completes
connections out of the kernel's backlog. The startup-timeout errors
name the seat-holding pid, with the kill advice identity-gated so a
stale record never tells anyone to kill an innocent process.

Two liveness corrections round it out: a zombie now reads as dead — it
answers kill(pid, 0) like the living but holds no lock and no image,
and no signal can end it, so counting it alive spent both reap timeouts
on a corpse (the GUI never waits on the daemons it spawns, so crashed
daemons are zombies as a rule) — and stop() only pays the
process-exit wait for a shutdown it actually delivered, instead of
watching an unreached survivor not move for five seconds.

The guard tests were each verified to fail against the behavior they
pin (fallbacks, the handshake criterion, the grace, and the wait gate
removed by mutation) before being trusted green; the zombie probe
semantics (proc_pidinfo failing for a zombie that still answers signal
0) were measured, not assumed.
2026-08-17 15:11:16 +08:00
l0ng-ai 89e4ae833d fix(terminal): stop hidden panes from repainting the whole window (#670)
* fix(terminal): stop hidden panes from repainting the whole window

Every pane's PTY pump ended a batch with an unconditional window.refresh(),
and a pane in a background tab still resolves to its window there — so any
hidden pane producing output pinned the visible tab at full frame rate.
With 30 tabs, 29 of them chatty in the background, the window repainted at
a steady 60 calls/s and the GUI process sat at ~45% CPU with nothing
visible changing.

Dropping the refresh is not enough: the chrome reads every pane entity
while the window draws, so gpui tracks them all and a hidden pane's
notify() dirties the window anyway. The pump's Wakeup notify is now gated
on a per-pane displayed flag — an Arc<AtomicBool> outside the entity map,
declared each frame by the root render (active tab true, everything else
false). Flags default to displayed, so a path that never declares can only
cost extra repaints, never a frozen grid. Low-frequency events (title,
exit) keep notifying unconditionally so tab chips stay fresh.

Same load after the change: ~20 renders/s driven only by the visible pane,
~520 background wakeups/s suppressed, and an idle window with 30 quiet
tabs sits at a few renders/s.

* test(terminal): pin the output gate's semantics; scope the registry per app

The displayed registry moves from a process-wide static into a gpui
Global. Entity ids are only unique within one App, and parallel gpui
tests each mint their own App with colliding id sequences — through a
static, one test's frame declarations could flip another test's pane
flags. The shipped binary runs exactly one App, so behavior there is
unchanged.

Three tests now hold the gate to its contract: the active tab's panes
count as displayed and a tab switch hands the frame loop over; a pane
nobody declared (and an id nobody registered) errs toward displayed,
because the failure direction that matters is a visible pane that stops
repainting; and a released pane's flag does not outlive it.

Also restores touch_active_tab's doc comment, which the previous commit
had accidentally fused onto declare_displayed_panes.
2026-08-17 15:08:15 +08:00
l0ng-ai 2d517fa0f3 ci: pin the GITHUB_TOKEN to read-only in the CI workflow (#665) 2026-08-17 14:48:03 +08:00
l0ng-ai 9f34cd3501 fix(editor): stop scrolled-out text painting over the line numbers
Bump the gpui-component fork to 070d1a2, which clips the editor's scrolling
content to the right of the gutter. Text, selections, indent guides and the
cursor all paint from a bounds origin that horizontal scrolling has already
shifted left, so scrolled-out content kept painting under the line-number
column; the only thing hiding it was the gutter quad painted afterwards,
which works only while `editor.gutter.background` is opaque.

`apply_theme` clears that key to transparent so the panel can sit on a
gradient or image window background without a seam, which is exactly the
case the upstream code does not cover. Note that dependency in the theme,
so the next person to touch it knows the transparent gutter is not free.
2026-08-16 19:05:53 +08:00
l0ng-ai 95305d50dd fix(sidebar): give the tab rows a width that resolves
The rows, their group blocks and the scroll area all ask for `w_full`, and
a percentage is only a width while some box above it has a real one. The
column inside the rail declared `size_full`, which is another percentage:
on the passes that size that column from its content there was nothing for
any of them to resolve against, so every row fell back to hugging the
longest tab name and the active row's capsule stopped well short of the
rail's edge.

Hand that column real pixels instead. The rail is `w(px(width))` and layout
is border-box, so its content is one pixel narrower because of the right
border. With a definite width there, the whole chain below resolves — which
also makes the same trick on `workspace_head` redundant, though it is left
in place as a harmless explicit width.

`w_full` on the scroll area itself is the second half: a stretched width
sizes it the same but not definitely, and the rows inside need a definite
one to be a percentage of.
2026-08-16 19:05:53 +08:00
l0ng-ai 0295a98915 feat(sftp): open remote text files in the built-in editor
A click on a file in the SSH Files panel used to start a download; the
only way to change a remote file was download, edit, re-upload. Now a
click opens it in the built-in editor and Cmd-S saves straight back over
the pane's own SFTP channel, matching what the Files panel already does
locally and over a remote workspace.

- protocol: SftpOp::ReadFile/WriteFile and SftpOpResult::File, bytes as
  base64; the reply carries the body plus the stat it was read under
- daemon: ReadFile enforces the caller's size ceiling before and during
  the read; WriteFile rewrites in place (truncate, not temp-and-rename)
  so the file keeps its mode and ownership
- SftpHost: a Host over the pane's SFTP route, so the editor's existing
  open/save path works unchanged; git/search/watch honestly Unsupported
- editor: an open buffer holds the host it was read from, and
  save/reload/dedup/watch key on (host, path) instead of the active host
- panel: single click opens (dirs navigate, text files edit), the same
  gesture as the local tree; binary or oversized files get the local
  tree's toast, and Download moves to the context menu

Review follow-ups, in this PR: the SFTP host stays out of HostRegistry,
which means "a machine this window has a link to" and is swept as such —
filing the pane's channel there made Cmd-S return silently once a
workspace deletion took it back out. The cursor-jump lookup, the status
bar's path, and the SCM panel's repository all key on the buffer's own
host now. Closes #656.
2026-08-16 18:53:27 +08:00
l0ng-aiandl0ng-ai 7b0660bd42 fix(sidebar): give the workspace head a width that always resolves (#662)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 18:44:08 +08:00
l0ng-aiandl0ng-ai 6e193c404f fix(editor): kill paths one component at a time on ctrl-w (#658) (#659)
The built-in command editor intercepts ctrl-w before the shell sees it,
and its whitespace-only word boundaries killed a whole path in one
stroke. fish binds ctrl-w to backward-kill-path-component, so users
coming from kitty or Terminal.app expect /usr/local/bin to go one
segment at a time.

Mirror fish's path-component word motion: at most one run per character
class, separators (slash, equals, quotes, ...) end a kill next to
whitespace on their own. alt-backspace keeps the coarse
whitespace-delimited kill, matching fish's split between the two chords.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 18:17:59 +08:00
l0ng-aiandl0ng-ai ccd21fe97d fix(windows): keep a restored screen out of ConPTY's viewport, and stop Restart Server crashing the window (#657)
* fix(restore): keep a restored screen out of ConPTY's viewport

On Windows a restored pane came back with its shell drawing in the wrong
place: the prompt stopped responding where it stood and the restored text
filled with fragments of whatever was being typed.

A ConPTY does not hand the terminal a stream, it hands it a rendering of a
screen buffer conhost owns, addressed absolutely and counted from that
buffer's top-left, which starts blank with the cursor at (0,0). PSReadLine
redraws the line being typed as `ESC[6;20H ... ESC[6;26H` on every
keystroke, and conhost frames what it paints the same way. Those row
numbers are only right if the client's viewport is conhost's buffer, row
for row.

Restored output is output conhost never produced and knows nothing about.
Left on screen it shifts every row conhost names, so the first repaint of
the input line lands on the old text. Nothing the client can do fixes it
afterwards: the offset is not constant, and it would have to be unpicked
from every absolute address in the stream.

So the restore preamble now ends by scrolling the restored screen out of
the way. `ESC[2J` on the primary screen scrolls the viewport into history
rather than erasing it, so the screen the daemon restored is one scroll up
rather than gone, and `ESC[H` leaves the cursor where a fresh ConPTY
expects to find it. Unix keeps the old behaviour: a shell there positions
itself relatively, so the restored screen can stay where it can be seen.

* fix(restart): stop Restart Server taking the window with it

Clicking Restart Server made the whole app disappear, with a double-lease
panic in the crash log: cannot read Tty7App while it is already being
updated.

The work that puts the window back together after the restart ran inside
`update_in` on this window's own entity, and it ends by rebuilding every
local window from the machine tree. The first thing that rebuild asks each
window is which tabs it is showing, which it reads back out of the window
registry — so the first window it reaches for is the one the closure
already holds leased, and gpui answers a double lease by panicking, which
on the main thread is the process.

Split into `settle_after_restart`: the window's own state first, then the
resync outside the lease, then the focus. The resync still runs either way
the restart went, because a refused handoff leaves the daemon serving the
panes this window already dropped (#554).

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 17:49:54 +08:00
webdev ac3c95a647 feat(update): install verified Linux AppImage releases in app (#306) (#652)
The last platform from #306: a Linux install running as an AppImage can
now download, verify, and apply a release from inside the app, through
the same tty7-updater helper the macOS (#309) and Windows (#330) paths
use. Tarball and distro installs are deliberately untouched — they keep
the named-package hint and the release page, because replacing a file a
package manager may own is not this code's call to make.

The installed artifact is one file, the path $APPIMAGE names, so the
install is the simplest of the three platforms: stage the download
beside the image (two renames only stay atomic on one filesystem),
verify, swap, relaunch, and restore the preserved previous image if the
new one does not survive its launch grace. What is Linux-shaped about
it is the mount: the image the GUI runs from is FUSE-mounted by the
AppImage runtime and torn down when the app exits, which is the moment
the installer starts working — so the GUI copies the helper out of the
mount into staging and runs the copy, the way the Windows path runs a
private copy because Setup replaces the installed one. The daemon is
left running throughout, as on macOS: nothing on Linux locks a running
executable's file, and the panes it serves are the reason the update
restarts only the GUI. The swap also carries the installed image's own
mode onto its replacement, so a 0700 image stays private and the
download's missing execute bit never reaches the installation.

Verification holds the issue's requirements with what an unsigned ELF
can offer: the bytes must match the release's checksums.txt, the file
must actually be a type-2 AppImage — a mis-published asset fails with a
name instead of at launch — and the image must state the version it
claims. That statement is new: bundle-appimage.sh stamps
X-AppImage-Version into the desktop entry, and the updater reads it
back with one --appimage-extract, answered by the runtime before any
application code and without FUSE. The same pass requires the new image
to bundle its own tty7-updater, because an image without one would
install fine and then be the last version that ever could.

release.yml and nightly.yml now build the updater on the Linux leg and
bundle it into the AppImage, and both check the packaged image for the
same facts the updater checks on a user's machine — helper present,
version stamped — so a packaging mistake fails the workflow instead of
the update. The first release carrying this can only bootstrap: images
already installed predate the helper and keep the manual hint, so the
first complete in-app update is the release after it.
2026-08-16 17:43:52 +08:00
l0ng-aiandl0ng-ai 0df604054d fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop (#655)
* fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop

Quit-and-stop could strand a daemon that had already unlinked daemon.sock
and deleted daemon.pid but never finished exiting: libc exit() runs atexit
handlers and static destructors beside dozens of live threads, and a
finalizer that blocks leaves the process holding the singleton lock with no
name on disk. Every later launch then spawns a daemon that stands down
against the lock and times out red, forever.

Three changes, each a fallback for the others:

- on_shutdown keeps the pidfile: once the endpoint is unlinked it is the
  only handle anything has on a process that is not gone yet. A pidfile
  that outlives a clean exit was already handled by recorded_daemon_is_dead
  and the reap path.
- The daemon exits through _exit(2) (after flushing the logger), skipping
  the atexit/destructor window entirely; everything owed to disk is flushed
  explicitly in on_shutdown.
- spawn::stop reaps with the pid it captured before asking the daemon to
  die, instead of re-reading a pidfile an old build's shutdown may have
  wiped mid-stop; reap_recorded_daemon keeps the pidfile when the process
  survives even SIGKILL, so the next attempt still has someone to reap.

* review: fix stale stop() comment, pin the mid-stop pidfile-vanish ordering in the test

The comment at the top of stop() still claimed a clean shutdown removes
the pidfile, which this branch just made untrue; it now states the real
reasons the pid is captured early. The vanishing-pidfile test now asserts
the sweeper's delete actually landed while stop() was waiting, so a
future shrink of PROCESS_EXIT_TIMEOUT cannot silently turn it into a
weaker scenario.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 17:35:39 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 17:05:34 +08:00
l0ng-aiandl0ng-ai 3ef644d267 fix(scm): keep the sync tile on the branch row at any panel width (#650)
* fix(scm): keep the sync tile on the branch row at any panel width

The branch row's flex constraints were set on the Button, but
`dropdown_menu_with_anchor` hands that Button to a `Popover`, which wraps
it in a plain div and never applies the trigger style it was given
(`trigger_style` is stored and never read). The constraints landed inside
a box that still measured its own content, so at the panel's 216px floor
a 24-character branch name overflowed the row and pushed the sync tile
out of the panel entirely, with no way to reach it.

Carry `flex_1` on a wrapper instead, and truncate the name against the
width it is actually given rather than against a character budget that
was guessing at that width. The `elide_middle` ceiling is gone: stacked
on top of a real truncation it produced two ellipses in a row
(`fix/new-tab-……`) and threw away the tail it existed to keep.

Notes and chips move into one `overflow_hidden` group that is allowed to
shrink, so the order of who gives way is explicit: branch name first,
badges second, the tile never.

Also stop offering "Publish Branch" from a HEAD that cannot publish. A
detached or unborn HEAD has no upstream by definition, so the token fired
there unconditionally — the widest thing on the row, naming the one
operation the tile beside it already refuses (#545), and on its own
enough to push that tile off a 216px panel.

* fix(scm): drop the branch row's note box when it holds nothing

The row lays the notes and chips out in one shrinkable box so the sync
tile keeps its place. An empty box is still a flex item, so the row's
6px gap was spent on either side of nothing: on the quiet branch that
is most of what anyone looks at, the caret sat 12px off the tile
instead of 6px. Build the notes first and only add the box when there
is something in it.

Also drop two comment citations of #549, which is about palette
commands that no-op silently and has nothing to do with this row.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 15:18:12 +08:00
l0ng-aiandl0ng-ai 05de7ae33a fix(new-tab): keep the SSH menu inside a menu's shape (#649)
* fix(new-tab): keep the SSH menu inside a menu's shape

The saved-host rows carried names and endpoints long enough to drag the panel
out to the 500px ceiling PopupMenu falls back to, and the row that meant to
elide was clipped mid glyph instead. The menu now stops at 360px, and a row
that runs out of room cuts the endpoint first — the name is what the reader is
picking by, so it keeps whatever is left rather than being squeezed to "..".

The height ceiling moves up to fit the shape everyone actually sees — nine
shells, both headings, six hosts and the two closing rows — so the default menu
arrives whole instead of scrolled with "Local" cut off above, and is capped
again against the window so a short one never gets a menu taller than itself.

The rule above the split hint goes: a separator divides two lists of things to
pick, and the hint is a footnote about the list it follows.

Bumps gpui-component, where a scrollable PopupMenu painted a scrollbar whether
or not it overflowed, custom rows could not elide, and labels had no padding of
their own.

* fix(new-tab): measure the menu ceiling off the viewport, and elide nameless hosts

`window_bounds()` answers how a window should be reopened after it is
closed, so a fullscreen macOS window reports the bounds it would restore
to rather than the screen it currently fills. A terminal spends much of
its life fullscreen, where that reading capped the menu at 80% of a
window nobody is looking at — putting back the scrollbar and the
cut-off `Local` this branch is here to remove. `viewport_size()` is what
every other window-relative size in the app already measures against.

A host saved on its address alone is *named* `user@host:port` and carries
no note, so it took the plain-item path — bare text with nothing to elide
against, on the longest string in the menu and the row least able to cut
it. Every host row is a custom element now, and `menu_row` drops its
right half when the note is empty rather than holding the gap open with
a zero-width child.

Also drops 17 unrelated dependency downgrades that rode along with the
`gpui-component` bump. The lockfile moves only the three `source` lines
it meant to; `cargo check --locked` accepts it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 14:24:22 +08:00
l0ng-ai 77590b5c70 docs: bring back a single platforms badge, in blue 2026-08-15 14:02:42 +08:00
l0ng-ai 7aacbdca7e docs: fold platform support into the subtitle, point the version badge at releases 2026-08-15 13:50:53 +08:00
l0ng-ai b884aa36c5 docs: restore per-OS platform badges with a flat Windows mark 2026-08-15 12:12:52 +08:00
l0ng-ai 8c875d8be6 docs: swap the OS logos for a single shell-prompt platform badge 2026-08-15 12:07:28 +08:00
l0ng-ai 9673c1ab0b docs: inline the Windows logo, Simple Icons no longer ships one 2026-08-15 11:58:03 +08:00
l0ng-ai a59d6ec28d docs: give the platform badges their own row with per-OS logos 2026-08-15 11:30:15 +08:00
l0ng-ai 6031570798 feat(new-tab): reach saved SSH hosts from the New Tab button (#647)
Adds a saved-SSH-hosts section to the New Tab menu on both the tab strip and the sidebar, ordered by frecency, with a row that opens the full host palette. Holding the modifier opens the host in a split instead of a tab.

The menu scrolls once the rows outgrow the popup, long host names and their endpoints truncate rather than overflow, a host with no name of its own draws its endpoint once instead of twice, and the rows are built when the menu opens rather than on every painted frame.
2026-08-15 11:28:42 +08:00
l0ng-ai 44bee953da docs: badge the supported platforms in both READMEs 2026-08-15 11:26:56 +08:00
l0ng-ai cf6df5b469 fix(ssh): cover the whole window with the password prompt's scrim, and name the machine in a failed reconnect (#645)
Hoists the SSH password prompt's overlay from the body area to the window root so its scrim covers the title bar, tab strip and side panels, and aligns its top offset with the switcher card.

Replaces the raw target string with the resolved machine label on the reconnect banner and on the connecting pane, so a profile-backed machine no longer shows a bare config UUID in "Connecting to …" or "Could not reach …".
2026-08-15 11:11:26 +08:00
l0ng-ai f1144deb9f fix(ui): restyle the in-app notification to sit in tty7's own visual language (#646)
Bumps the gpui-component pin to pick up the notification restyle: flow-positioned status icon and close button that centre on the first line at any wrap count, a hairline-shadow surface in light theme, and a type ranking expressed in rems so it survives the ui_font_size setting.
2026-08-15 11:03:13 +08:00
ARNOandl0ng-ai 2e6103cf19 Retire to the tray on window close; cold start no longer stalls on stale daemon files (#639)
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files

Two problems shared a root: the daemon outlived every window, and
nothing could stop it gracefully.
Window lifecycle:
- Closing the last window retires the app to the tray instead of
  quitting (QuitMode::Explicit), so the daemon stays reachable. The
  tray restores the most recent workspace, and Quit — after the
  confirmation that protects running shells — stops the daemon. Every
  explicit exit path (tray, palette, keybinding) now stops the server;
  no exit leaves an orphaned daemon behind a dead icon.
- A pathless launch (double-click) hands off to the registered GUI via
  GuiOpen(None) and exits, instead of starting a second process with a
  second tray icon.
- The tray subsystem initializes once per process; reopening a window
  no longer creates a duplicate icon.
Cold-start robustness:
- Liveness connects are bounded to 500 ms, the version handshake times
  out in 1 s, and an unresponsive daemon is reaped by its recorded pid
  instead of polled for a 6 s graceful stop.
- A dead recorded pid skips the TCP probes entirely — the GUI's
  ensure_running, the new daemon's endpoint check, and the
  control-listener occupancy check (which could also misread a reused
  port as a live control server and refuse to boot). Stale cleanup now
  also removes the leftover control.port.

* fix(daemon,gui): skip the GuiOpen handoff probe when the recorded daemon is dead

* fix(lifecycle): keep the stale-endpoint cleanup, and do not retire into a tray that is not there

Three gaps in the tray-persist and cold-start work.

`ensure_running` moved the refused-connect branch under the new liveness
check, so a connect that fails while `recorded_daemon_is_dead` says "not
dead" now skips the reap and the stale-endpoint removal entirely. The
pidfile answers "not dead" to two cases it has no evidence about: it is
missing (the daemon died between `transport::bind`, which writes
daemon.port, and `pidfile::write_current`), or it records a pid the OS has
since reused. Both then leave daemon.port on disk and the spawn poll pays
the OS's refusal delay on it — the cost this path was rewritten to avoid.
Restore the branch, and split the rule into `recorded_daemon_is_dead_with`
so a test can state that a missing pidfile is not evidence of death,
without an env var every parallel test would inherit.

The tray's windowless Quit stopped the server without a prompt, reasoning
that the confirmation is about the panes behind a window. It is not: it
says "anything still running in your shells is terminated", and retiring to
the tray is precisely what leaves those shells running with no window. Bring
the window back and deliver the action to it, so the confirmation appears;
only when no window can be opened does the bare stop remain, with a warning.

`show_tray_icon` is a request, not an outcome. `Backend::create` can fail
for a whole run — a Linux session with no StatusNotifier host is the
ordinary case — and after MAX_ATTEMPTS the loop gives up and logs. Retiring
on the config alone then leaves a process with no window and no icon: not
reachable, and still holding the daemon. Gate the retirement on an icon
actually being up.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 23:04:15 +08:00
ARNO 4f1e181bdf feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks (#637)
* feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks

feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks
Nushell was the only detected shell with no integration: shell_kind
never matched `nu`, so panes were spawned with no OSC 7 cwd reports
and the daemon could not follow `cd` — pwsh, zsh, bash and fish all
do. Recent Nushells emit their own OSC 133 marks and a Windows
`OSC 9;9` cwd protocol, but tty7 only consumes OSC 7, and the native
`shell_integration.osc7` toggle defaults to off.
The injection rides `nu --config` (Nushell has no ZDOTDIR analogue): a
throwaway config.nu sources the user's own config.nu back in first,
then appends hooks. `source` is a parse-time construct in Nushell — it
cannot be guarded at runtime or name a missing file — so the Rust side
resolves the path with the same rules `$nu.default-config-dir` uses
(APPDATA / XDG / HOME) and substitutes a literal, or a no-op line when
there is no config.nu. The hooks report cwd (OSC 7, %-escaped, `/C:/…`
shape on Windows), prompt start (A) and the previous command's exit
(D, gated on a flag the pre_execution hook arms so the first prompt
emits nothing), mark command output (C), and wrap prompt_indicator for
the prompt-end mark (B) only when the config defines one — recent
Nushells' built-in prompt keeps drawing its own indicator and B mark,
and overlapping marks merge in the daemon's prompt-state machine.
Remote SSH and WSL panes are unchanged: their bootstraps cannot carry
a Nushell config, and a nu-as-login-shell session would break on one.
Tests: static assertions on the script, setup dispatch, literal
substitution, and a Windows real-PTY cycle asserting OSC 7 follows
`cd` and every mark arrives with the correct exit status.

fmt

* fix(shell): resolve the Nushell config dir the way nu does

The wrapper's `--config` replaces the user's config.nu entirely, so the
path it sources back must be the one nu itself would load — anything
else silently strips macOS panes of their prompt, aliases and
keybindings. `dirs::config_dir()` is `~/Library/Application Support`
on macOS, not `~/.config`, and nu-path consults `$XDG_CONFIG_HOME` on
every platform (Windows included) but only when it is non-empty and
absolute. The resolution now mirrors that: a per-platform default plus
XDG winning only in the exact shape nu accepts.
Also gate the OSC 7 backslash translation on Windows
(`$nu.os-info.name`) so a Unix path with a literal backslash survives,
and harden the real-PTY tests: one line per submitted command
(reedline drops input while a command runs) and a grace period after
the D mark so the cwd report that follows it in the same prompt cycle
lands in the transcript.
2026-08-14 22:32:47 +08:00
l0ng-ai 5c284799aa fix(tabs): stop a command that is over in a blink from flashing across the tab
The tab title follows the terminal's OSC title, and nearly every prompt
framework sets that to the command it is about to run and puts the old
title back at the next prompt. For anything that finishes in a blink both
edges arrive within a few frames, so the label showed the command and
snapped straight back — a flicker that reads as a rendering glitch rather
than as information.

Hold a new title for 400ms before the tab adopts it. A title that reverts
inside the wait matches what the tab already shows and drops the pending
one, so a short command never reaches the label at all; one still running
when the wait elapses names the tab as before, 400ms later.

A second title arriving mid-wait rides the wait already in flight instead
of restarting it. Restarting is what would let a program that rewrites
its own title faster than the wait — a download reporting progress —
put the tab's next update off for as long as it ran.

Child exit clears the pending title and writes its own immediately: a
title still waiting its turn would otherwise land on top of "(process
exited)" a moment later.
2026-08-14 22:30:58 +08:00
l0ng-ai 3d6528737a fix(settings): give the page back its scroll range, and hold the bar off the window corner
The centring added in #631 turned the settings content box into a flex
column, and that cost the page most of its scroll range: the box is an
item of the scroll pane, which is itself a flex column, so its height
came out of a negotiation with the pane rather than from the rows it
stacks. `content_size` is just that box's laid-out bounds, so the range
ended a screen short of the last row — dragging to the bottom still left
content cut off. `flex_shrink_0` does not help; the height is agreed,
not squeezed. Centre with `mx_auto` on the column instead and leave the
box a block, which reports the full height it stacks.

While there, hold the content scrollbar 12px clear of the top and
bottom. Every other list this bar serves sits in a bordered panel where
running the full height is right; this pane is the window, and a bar
drawn to the last pixel lands on the rounded corner. New
`with_inset_vertical_scrollbar` takes the inset, and the existing
`with_vertical_scrollbar` keeps its behaviour for the other twelve
call sites.
2026-08-14 21:47:08 +08:00
l0ng-aiandl0ng-ai f08d8c2764 fix(remote): stop the server on machines that have no /proc, and show the install on the strip (#627)
* fix(remote): stop the server on machines that have no /proc, and show the install on the strip

Restarting the remote server timed out after ten seconds on every Mac and
BSD, with the old daemon still running and the new binary already sitting
next to it, unlaunched.

Both the probe that finds the running `tty7-server-*` and the command that
terminates it walked `/proc/[0-9]*` and read each `exe` symlink. There is no
`/proc` there. Two things then went wrong at once. zsh is the login shell on
macOS, and it aborts the whole command line when a glob matches nothing, so
even the trailing `true` never ran; and `cycle_daemon` discards the result of
the terminate, so a command that killed nothing was indistinguishable from
one that worked. `daemon_is_serving` then answered yes until the deadline.

Guard the glob behind `[ -d /proc ]` — unreached, it is never expanded, so
zsh has nothing to abort on — and fall back to `ps`, whose `comm` is the full
path on the BSDs. It cannot be the only branch: Linux truncates `comm` to 15
characters, one short of `tty7-server-c7p5`, which is why `/proc` stays the
first choice where it exists. `check_running_build` reads the same probe and
was equally blind on those machines; it can see now.

Separately, the install progress bar only ever existed inside the switcher.
Pressing Update Server from a parked workspace with no switcher open froze
the window for the length of the download and then produced a modal, with
nothing in between. The strip draws it too now — caption and bar from the
same source the switcher uses, and no button while an install is in flight,
since pressing it again would start a second one on top of the first.

* fix(remote): say why a stop failed, and stop a leaked install from eating the strip's button

Three things the no-/proc fix left standing.

`cycle_daemon` still discarded the terminate's result, which is the other half
of why a Mac cost a bug report: the command ends in `true`, so anything short
of success means the far end never reached the kill at all, and that is exactly
what a zsh abort looks like. It is now logged, and named in the timeout error —
"the running remote daemon did not stop within 10s" on its own blames a daemon
for ignoring a request nobody managed to send it.

The strip hides its Update Server button whenever an install is in flight,
which is right, but it reads the progress registry with no link state to temper
it — unlike the switcher. `finish_connect` bows out before clearing that entry
whenever `connect` has moved on in the meantime, and a switcher disconnect or a
move to another workspace both do that mid-install. The leftover froze a
progress bar on every window pointed at the machine and took away the one
button that could have fixed it. Cleared where the attempt actually ends
instead, however it ended.

The switcher kept its own copy of the progress bar after the caption was
shared; it draws the shared one now.

Tests: the probe runs for real in every shell on the machine rather than only
parsing under `sh -n` — the glob that started this was valid syntax and only
fell over when zsh ran it, which no `-n` can see. `ps` is checked on its own
where the fallback would actually be taken, since that arm eats its own stderr
and a rejected flag would otherwise cost nothing visible. And a stop that fails
is asserted to reach the error.

`with_shutdown_timeout` exists so that last test does not sit out ten seconds.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 19:15:01 +08:00
l0ng-aiandl0ng-ai 3bc8a764f7 fix(theme): stop the code editor painting its gutter and current line in the stock syntax theme's colours (#636)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:58:33 +08:00
l0ng-aiandl0ng-ai 34957f8659 docs(shell): name custom arguments as a reason integration never engaged (#634)
A pane that never armed shell integration blamed a PTY wrapper or an unsupported shell setup. Since #629 a zsh or fish the user gave arguments to is deliberately left alone, so the notice now names that first — it is the one cause the user can undo. All three locales.

The release notes gained the matching entry: the change turns integration off for an existing config that sets `shell` or a `custom_shells` entry with `args`, which is worth stating outright.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:57:24 +08:00
l0ng-aiandl0ng-ai 52b823a9f3 test(history): stop the sweep test deleting the panes of tests running beside it (#638)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:56:44 +08:00
webdev 7fded5afd4 fix(tree-sync): record the panes a window seeded in its own mirror (#628)
A window's mirror of its machine held no `PaneRecord` for a pane the window itself created. Records ride inside layout deltas and a client is left out of the deltas its own ops raise, so the record the daemon mints when it registers a seed reached every window but the one showing the pane; `PaneFacts` closed the gap only when a fact changed, and a pane spawned into its directory and left at its prompt never changes one. The workspace answered no subject path, an unnamed one read "Untitled", and `record_geometry` stamped a null subject over the path views.json remembered.

The window knows what it seeded, so it now puts those records into its own mirror through the same `PaneSeed::into_record` the daemon mints with — opened up rather than duplicated, so ssh-secret stripping stays shared — with the window's own Ready terminals standing in for the daemon's liveness probe. The write is insert-only: a record the mirror already holds came from the machine and outranks what a seed knows.

Also closes the race that reopened the same symptom by another route: a `MachineGet` already in flight installed its tree whole and took the client's not-yet-acknowledged writes with it, and nothing re-inserted them until the next non-empty op. All four optimistic writers now go through one path that keeps each write for the life of a pull in flight and replays it over the tree that lands. The tree stays authoritative for everything it speaks about — only the ops it was built too early to know are put back on top, and the journal is drained once it has landed, so nothing a later tree dropped is resurrected. This covers #604's pushed tabs and workspace ops, not only the seeded records.

Fixes #612.
2026-08-14 16:17:07 +08:00
webdev 84a424006e fix(resize): defer the reflow to the daemon's Size echo on remote routes too (#632)
Since #415 the daemon echoes a `Size` frame at the stream position where the pty changes geometry and the client defers its grid reflow to that marker — but only on local routes, so a remote pane resized mid-flood still parsed queued old-width bytes into the new-width grid, which network transport makes worse.

Rather than probing `Version` per pane (a whole routed connection, and for ssh/WSL a whole bridge process, on every spawn and attach), the server advertises the pane protocol's features on its control hello. The answer is cached on the link and read off the host when a pane's route is built, and the route carries it to the terminal at spawn, attach and relink. This is additive within `CONTROL_VERSION` 7: no new field, just extra names in the existing `ControlHelloOk.features`, so an older client cannot choke and an older server that names no echo makes the client reflow at request time as before. A route built while the link is down answers false.

Known limitation, inherited from #415's design and not introduced here: there is no timeout if a promised echo never arrives — once deferred, a later identical resize neither re-sends nor reflows, so a wrongly-set bit would freeze the grid at the old geometry. Every traced path makes the control hello and the pane daemon the same build, normally the same process.

Closes #416.
2026-08-14 16:02:04 +08:00
webdev 422808191d feat(sidebar): group a tab by its folder when its cwd is not a repo (#631)
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.

The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.

Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.

Closes #620.
2026-08-14 15:57:36 +08:00
webdev 0346e35b40 fix(shell): stop injecting into a zsh or fish the user gave arguments to (#629)
The zsh and fish arms of `shell_integration::setup` never checked `has_custom_args`, so a shell the user launched with their own arguments was injected anyway — fish had `-C <script>` appended to its argv, zsh had its ZDOTDIR swapped. Both arms now sit behind the same gate bash, PowerShell and WSL already used, hoisted to a single early return ahead of the dispatch so a new ShellKind cannot silently reintroduce the bug.

Docs now describe what the code does: the `shell` row's own `{"program": "fish", "args": ["-l"]}` example loses integration under this rule, and the shell-integration note distinguishes user-written arguments from the ones detection supplies (Git Bash, WSL).

Part of #624; the native-input-mode half is separate.
2026-08-14 15:57:20 +08:00
l0ng-aiandl0ng-ai 72db26d15a feat(prompt): let the shell's own line editor own the prompt (#633)
Closes #624

tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.

The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.

The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.

`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.

Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.

Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 14:46:22 +08:00
l0ng-aiandl0ng-ai b3698f4b59 fix(worktree): lift the new-worktree prompt to a window-level modal (#626)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 09:35:22 +08:00