Commit Graph
873 Commits
Author SHA1 Message Date
l0ng-ai b23e0feb55 fix(editor): closing a tab no longer throws away unsaved edits
The code panel hangs off the tab, so closing the tab drops every buffer
in it. `editor_close_file` asks before closing a *file*, and the file
tree marks a dirty one — but nothing outside `code_editor` read `dirty`
at all, and `tab_close_reason` looked at `pane.terminals()` and nothing
else. Every close path that went through the tab took the edits without
a word.

The ordinary ⌘W is one of them. `CloseActiveTab` reaches
`editor_close_active_if_focused` only while the editor has focus; with
focus in the terminal — which is where it is after you edit a file and go
back to the shell — it goes to `close_pane`, and a last pane takes its
tab. Type into a file, click the terminal, press ⌘W, and the text is
gone with no dialog.

`UnsavedEdits` is checked first wherever a reason is derived. Of the
three, it is the only loss that cannot be undone by doing the thing
again: a killed command can be re-run and a dropped SSH link
reconnected.

Three paths, because the tab can end from three directions:

- `tab_close_reason`, for a tab closed as a tab.
- `focused_pane_close_reason`, because `close_pane_inner` carries its own
  `confirmed` into `close_tab_inner` so the tab does not ask twice —
  which means a question the pane never asked is never asked at all.
- the bulk closes, which pass `confirmed` and so cannot ask. They skip
  instead, on the same footing as an SSH profile that asked to be warned
  about. Deliberately not the footing of a busy tab: busy is the state
  most tabs on a working window are in, so skipping it would close
  nothing, while unsaved edits are rare and unrecoverable.

Both loss paths were run against the unfixed code and fail there. The
third test — a saved buffer still closing outright — passes either way on
purpose: it holds the guard to the edits rather than to the mere presence
of a code panel.
2026-08-22 23:06:58 +08:00
l0ng-ai c27d88e001 fix(tab-strip): an ellipsis that stands for nothing dropped
`short_title` counted `~` toward a path's depth, so a home path was cut
one segment earlier than an absolute one of the same shape:
`/usr/local/bin` kept its root, `~/repo/025/tty7` became
`…/repo/025/tty7`.

The cut bought nothing. `~` and `…` are one grapheme each, so the label
is the same width to the character — it just says less, and what it says
is untrue: an ellipsis is a claim that something was omitted, and nothing
was. The reader cannot tell that tab from one whose ancestors really are
hidden.

`elide_path_middle` then believes it. It reads a leading `…` as "this was
elided once" and replaces the marker rather than keeping it, so narrowing
such a tab gave `…/025/tty7` where a real `~` would have given
`~/…/025/tty7`. The home marker was lost for good, to stand in for a
segment that was always there.

Counting segments only puts the home case on the footing the absolute one
already had. Four segments still elides, and the marker then means what it
says.

The one test that failed is the one that had written the old behaviour
down; it moves to a four-segment path, which is the case it was reaching
for, and the three-segment boundary gets a test of its own.
2026-08-22 22:40:12 +08:00
l0ng-ai c6e878281d fix(updater): read the designated requirement off the stream it is on (#708)
`codesign -d -r-` writes the requirement to stdout and puts only the `-d`
display header (`Executable=…`) on stderr. `signing_requirement` searched
stderr, so the `designated => ` prefix could never match and every in-app
update on macOS ended at "codesign did not report a designated
requirement" — every build, every channel, with nothing a user could do
but download the app again by hand.

Verified against codesign rather than reasoned about:

    $ codesign -d -r- /bin/ls
    stdout: designated => identifier "com.apple.ls" and anchor apple
    stderr: Executable=/bin/ls

Both streams are read now, stdout first. Which half goes where is
codesign's own business and has moved before; a requirement printed
anywhere in the output is the requirement, and the updater has no reason
to be the stricter party about where it appeared.

The parse is split out of the process call, which is the part that
matters for it staying fixed. Fused to `Command::output`, it could only
run against a real signed bundle, so nothing in a test suite ever
executed it — that is why a total failure of the macOS update path
shipped and stayed. `/bin/ls` is the bundle it was missing: Apple-signed,
on every macOS, and it answers `-d -r-` with a requirement of its own, so
the stream split is now asserted against the tool instead of against our
belief about it.

Both tests were run against the old stderr-only parse; both fail there.
2026-08-22 22:15:01 +08:00
l0ng-ai 3b5df0b6b5 fix(tree-sync): adopting a workspace is not creating one (#716)
`chosen_name` is the name a user typed for a workspace a window is about
to create. It travels with the create rather than following it, because a
rename sent before the workspace exists is answered `NotFound`.

When the create came back without that name, `settle_chosen_name` sent it
as a rename. Its own comment gave two reasons the create might not have
run — the other create of this window's pair won the race, or the
workspace was already there — and treated them the same. They are not the
same. The first is this window finishing its own job. The second is
renaming somebody else's workspace.

#716 is the second one from the far end: a client opened a workspace on
another machine that already held nineteen live panes, and the workspace
came back named after the connecting client's local user, because that
side spent a codename it had rolled for a workspace it thought it was
making.

A workspace this window's sibling create just made is empty, so the two
cases separate on whether the workspace holds tabs — and the existing
arbitration tests all pull an empty mirror, so they are exactly the case
that still renames.

The name is still consumed when the rename is declined. It was owed once,
and adopting the workspace is how it stops being owed; parking it would
only fire the rename at the next pull.

This is the naming half of that report. The tab tree it also lost is not
addressed here.
2026-08-22 22:10:05 +08:00
l0ng-ai f4df05ffca fix(sidebar): open the diff of the row whose counts were clicked (#706)
Every sidebar row carries its tab's `+N −M`, and the counts are their own
click target. They read the git path off `self.tabs[i]` — the row — and
then called `toggle_diff_overlay`, which writes to `self.active`. The two
are only the same tab when the row clicked is the one already in front.

Clicking another row's counts therefore opened *that* row's repository as
an overlay on the tab already in front: focus never moved, the front tab
showed a diff from a directory it has nothing to do with, and the overlay
stayed filed under the front tab afterwards, so every later read of "the
active tab's overlay" kept returning it.

The row's tab now comes forward first, and the overlay lands on it.

Arriving from another tab opens rather than toggles. `open_diff_overlay`
is itself the toggle — the close lives there, not in its `toggle_*`
wrapper — so activating the target first was not enough on its own: a
target that already had that diff open and in front answered a request to
show it by closing it, leaving the screen on nothing that was asked for.
`may_close` is what separates the two, and only a click on the tab
already in front sets it.

The bounds check is the same bug by another route: `activate` no-ops on
an index it does not have, which would leave the open writing to whatever
tab happened to be in front, and a row can outlive its tab between render
and click.

All three tests were run against the unfixed code, not only a green tree;
two of them fail there.
2026-08-22 22:02:26 +08:00
l0ng-ai 9963cfb8fc fix(settings): let the scroll slider reach the range it documents
`mouse_scroll_multiplier` is clamped to 0.1..=10, and the reference page
says so. The slider spanned 0.5..=5.0.

Two things followed. Half the documented range could not be set from the
window at all — 0.2 and 8 are storable, keepable values with no position
on the control that owns them. And a hand-set 8x drew a thumb pinned at
the slider's own maximum, three quarters of the way along a track whose
end means 5; the number beside it read "8.00x", so the control and its
own readout disagreed on screen.

Its two sibling sliders each span exactly their clamp — window opacity
0.2..=1.0, background-image opacity 0..=1 — so this was the outlier
rather than the convention. It now reads its ends from named constants
beside the clamp, which is where `ui_font_size` and the panel widths
already keep theirs (#550), so the two cannot part again. The step drops
to a tenth: the wider span has to keep 1.00 a position you land on rather
than something to be hit between two.

The test holds both ends through a `sanitize`, so the ends the slider
offers are ends the file keeps, and checks the reference page still names
the same pair — it is the third copy of the range and the one a reader
meets first. Checked against an injected wrong range, not only a green
tree.
2026-08-22 21:51:30 +08:00
l0ng-ai b5f8d484ba fix(palette): stop naming a keymap action the keymap has never had
`key_spec` maps a palette command to the keymap action whose shortcut the
row should show, and `effective_key` answers an action it does not know
the same way it answers a deliberately unbound one: `None`. So a name
that resolves to nothing does not fail — it quietly shows no shortcut,
which is indistinguishable from having none.

`RestartDaemon` named `"RestartDaemon"`, and the keymap has never bound
it. Nothing was visibly wrong today, because the command has no shortcut
either way; what was wrong is that the palette claimed a bindable action,
so binding one later would still have shown nothing. It moves to the arm
for commands with no action of their own. Restarting the server stays
palette-only: giving it a real action means a gpui action and a handler,
which is a feature rather than a fix.

The guard reads the names out of `key_spec`'s own source, because the
match *is* the list and a second copy here would drift the way the first
one did. Both it and the settings-index guard were checked against an
injected regression rather than only against a green tree — a phantom
action and a removed index entry each fail them.
2026-08-22 21:46:06 +08:00
l0ng-ai a92aef969d fix(settings): a row you can see is a row search can find
The two halves of settings search did not agree on what matching means.
`row_matches_query` accepts a bare label match, so a row highlights on
its own title; `section_match_count` counts only `settings_search_entries`,
and that count is what drives the nav badges, `best_matching_section`,
and the "nothing matches" note.

Eleven rendered rows were missing from that index, and the result was not
merely that they failed to highlight. Typing a row's own title into the
box made the page answer that nothing matches it, with the row sitting
right there on screen — measured, not inferred:

    "Interface font size"                   -> 0 matches
    "Zoom with the wheel"                   -> 0 matches
    "Skip banner"                           -> 0 matches
    "Shell integration"                     -> 0 matches
    "Import aliases"                        -> 0 matches
    "Custom path"                           -> 0 matches
    "Give each pane its own shell history"  -> 0 matches

All eleven are indexed now, with keywords in the three locales. The SSH
profile form's distinctive rows are among them — someone searching for
"x11" or "proxyjump" is better served by landing on the SSH page than by
being told the feature does not exist. Its four bare field labels
("Name", "Host", "User", "Auth") stay out: each is one common word, and
a one-word entry would put a match on the SSH badge for half the queries
anyone types.

The guard is the durable half. It reads the rows out of this file's own
source, because a hand-kept list would need exactly the discipline the
index needs and would drift the same way — it could not catch the index
doing it. A second test holds the four exemptions to naming rows that
still exist, so renaming one cannot quietly widen the exemption to
nothing.

A label may resolve to more than one key: the blur row is "Background
material" on Windows and "Blur" elsewhere, and the index is `cfg`-gated
to match. So the guard asks whether *any* of a row's keys is indexed
rather than picking the branch the test binary was built for.
2026-08-22 21:41:04 +08:00
l0ng-ai 19baa6af70 chore(lint): leave the workspace building without a warning
`cargo clippy --workspace --all-targets` printed twelve warnings; eleven
were style, and the twelfth was the one that matters — a build that
always warns is a build whose warnings nobody reads, so the next real
one arrives invisible.

`RemoteTerminal::{list_known_hosts, delete_known_host}` are the twelfth.
Their doc already argues they should stay — the daemon half is finished
and only the screen is missing — so the `allow` says the same thing to
the compiler instead of leaving the argument only in prose.

The rest are clippy's own suggestions, taken as offered, except two the
autofix could not make:

- `windows.rs` builds its test `Config` through struct-update now, which
  has to name the inner `CoreConfig`: `Config` is a newtype, and
  struct-update syntax does not travel through `Deref`.
- the input-bar position test iterates the slice instead of indexing it.
2026-08-22 16:52:40 +08:00
l0ng-ai 791d0d0cfa Merge remote-tracking branch 'origin/main' into polish/ralph-wc
# Conflicts:
#	README.md
#	README.zh-CN.md
#	crates/tty7-cli/src/cli.rs
#	crates/tty7-cli/src/server.rs
#	crates/tty7-core/src/core/config.rs
#	crates/tty7-core/src/core/git/status.rs
#	crates/tty7-core/src/daemon/install/wsl.rs
#	crates/tty7-core/src/daemon/protocol.rs
#	crates/tty7-core/src/daemon/spawn.rs
#	crates/tty7-core/src/daemon/ssh/mod.rs
#	src/terminal/completion.rs
#	src/terminal/remote.rs
#	src/ui/app.rs
#	src/ui/i18n/en.rs
#	src/ui/i18n/ja.rs
#	src/ui/i18n/zh.rs
#	src/ui/tree_sync.rs
2026-08-22 16:48:33 +08:00
l0ng-ai 74bb98697d Keep a stalled remote link off the UI thread (#709)
* fix(terminal): keep a stalled remote link off the UI thread

A pane's writing half was a blocking socket with no write timeout, written
to synchronously from gpui event handlers. When the far end stopped
draining — a congested remote workspace, where the router's
copy_bidirectional stops reading our half — the send buffer filled and
write(2) parked in the kernel. One UI thread draws every window, so that
was every window frozen until the link recovered. macOS gives a unix
stream 8K, which is about 1400 keystrokes: a single paste.

Move the socket onto a sender thread. write/resize/respond_auth/Detach
now encode a frame, push it onto a bounded queue and return; the sender
writes with the lock released and is welcome to park for as long as the
far end makes it. A second handle on the socket is kept for shutdown,
which returns at once even while another thread is parked in write(2) —
the only way teardown can break that state.

The backlog is bounded at 4 MiB. Reaching it is a dead link rather than a
slow one, and is reported through the same path — and once — as an
outright refused write. Refusals are now met on the sender thread, so a
pane learns of one a moment after the keystroke rather than during it.

Teardown gives what is queued 50ms to go out before cutting the socket:
on a draining link the sender is idle and Detach leaves in microseconds,
and on a stalled one it never leaves at all, which closing a pane must
not wait to find out.

* fix(terminal): a big paste is a paste, and a retired link keeps its own tongue

Review follow-ups on the pane-writer queue.

The "said it once" flag lived on the pane and was cleared on relink, but
the retiring sender still held the same `Arc`. A doomed write completing
after the reset spent the new link's one chance to speak, and the next
real refusal went unreported. The flag belongs to a link, not a pane, so
`LinkWriter::new` now mints its own.

A frame can be over the whole backlog bound on its own — `paste` sends
the clipboard as one `Input` — and refusing it marked a perfectly healthy
pane gone. An oversized frame onto an empty queue now goes through and
lifts the bound by its own size while it is outstanding, so what queues
behind it is still held to four megabytes.

Also: `close` is idempotent, so the teardown that calls it twice does not
spend two grace periods; a sender that has given up closes the queue
behind it rather than letting keystrokes pile to the bound it will never
drain; and the #673 note that was dropped in the move is back.

The backlog test passed with 27K of margin against a send buffer that is
8K on macOS but 212K on Linux, where the sender discounts what it got
onto the wire — it queues twice the bound now.
2026-08-21 16:05:16 +08:00
l0ng-ai 975e3edf9b Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn

Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.

Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.

"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.

RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".

Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".

Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.

* refactor(completion): stop declaring spec fields nothing reads

The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.

* refactor(daemon): delete the loopback-forward management pipeline

Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.

The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.

known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.

* test(ssh): cover the host-key policy table and both proxy handshakes

The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.

socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.

* test(cli,daemon): cover server binary resolution and the procargs parser

server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.

parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.

* test(ui): cover the host-op pool decisions and the local reconnect schedule

The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.

LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.

* fix(completion): unquote across the whole word, not just its first character

The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.

The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.

* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect

`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.

The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.

* test(file-tree): name the shell where the quoting rule is the POSIX one

`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
2026-08-20 23:33:26 +08:00
l0ng-ai 46759b8a01 fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704)
* fix(input-bar): read column widths from unicode-width, not a hand-rolled table

The input bar scored every character against a hand-written list of code-point
ranges. Anything the list missed counted as one plain column, so `🀄`, `` and
every combining mark pulled the rest of the row a column left, and clicks,
wrapping and the caret all landed off by that much (#701).

The grid gets its widths from `unicode-width` by way of `alacritty_terminal`,
so read the same table. Zero-width characters then need a cell to ride in:
group each base with the marks that follow it, so the shaper sees one run and
composes `é` instead of setting `e` and its accent side by side. An emoji
presentation sequence is re-scored as a string the way the grid re-scores it,
so `❤️` is two columns in the bar as well.

A ZWJ sequence stays two cells on purpose — that is what the grid makes of it,
and composing it here would put the bar a column off from where the text lands.

* fix(input-bar): derive click and wrap geometry from the cells the bar draws

`input_cells` re-scores an emoji presentation sequence to two columns and
hands a stranded combining mark a column of its own, but `input_char_positions`
kept walking the text character by character — so `❤️` was drawn two columns
wide and counted as one. Everything geometric read the short count: a click on
`X` in `❤️X` selected past it, wrapping broke a column early, and vertical
caret motion aimed at the wrong column.

Walk the same cells instead. Only the base of a cell carries the width, so a
click still lands on the base rather than a mark riding on it, and the riders
sit at the column the caret takes after the cell.

A cell now also tints as a unit when a selection covers any character in it —
it is one glyph, so half-highlighting it drew a mark unselected next to its
selected base.
2026-08-20 22:35:32 +08:00
l0ng-ai 07e3b26434 feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)
* feat(agent): outline a coding agent's conversation, and jump back to a turn

The hooks tty7 installs into Claude Code already announce every turn over the
pty as an OSC 777, and the daemon reads those for the pane's status dot. The
same bytes reach the client, where they are worth something else: the byte
offset a `prompt-submit` lands on is a *position in the stream*, so advancing
the emulator to exactly there and reading the cursor gives the scrollback row
that turn began on. That is an outline of the conversation, and a way back into
it — which is the one thing a long agent session in a terminal has never had.

The Info panel grows a CONVERSATION section: one row per turn, the prompt's
first line as its label, a dot that says whether the turn is still running.
Clicking a row scrolls the pane so that turn's prompt is the top line.

Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no
room for one at 260px, and a fourth variant would drop anyone who rolled back
to an older build onto Info. This is a fact about the pane, like its shell and
its cwd, so it sits with them.

The hook is a subprocess writing to the controlling tty while the agent's own
renderer writes to it too. Claude Code repaints in place with ink, so the cursor
when the hook's bytes land is wherever the last repaint left it — inside the
live region, a few rows from where the prompt's echo comes to rest. And once
the scrollback limit starts discarding lines, every anchor slides by the discard
count at once.

So the anchor is a hint, and the prompt's own text is the correction: at click
time (by which point it has long been drawn) the row is looked for around the
anchor, exact match first — the row that *is* `> hi`, marker stripped — and only
then by containment, which keeps its length floor because `hi` appears inside
half the rows of any answer. The row that is found is written back, so a second
click does not search again and cannot land somewhere else.

Claude Code keeps a JSONL transcript, and reading it would give the assistant's
side too. It would also only work for Claude, only when the agent runs on this
machine, and only for a path this process may read. An OSC comes back through
the pty from wherever the agent actually runs — over ssh, in a container, in a
remote workspace — with no file access and no per-agent format. What is lost is
the assistant's text; what is kept is every host tty7 supports.

- `OscTokenizer::feed_at` reports each payload's end offset. The client already
  tokenized OSC 777 on every batch to keep agent events out of desktop
  notifications, so the scan is free; only a real event now costs a cut, which
  is what #404 was right to object to about the old per-command mark scanner.
- `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending
  runs concatenated are not one, so a batch carrying both kinds is sorted —
  and only such a batch pays for it.
- A replayed ring is cut the same way, so reattaching to a pane rebuilds the
  outline from its own history rather than losing it with the old client.
- Turn anchors are dropped where image placements are: `clear_scrollback`, and
  the grid reset in `adopt_relink`.
- A turn that began on the alt screen is listed but not clickable — there is no
  scrollback behind it to return to.
- A turn announced twice is one turn. Hooks are not guaranteed to fire once,
  and what makes it the same turn is that the one before it never ended: a real
  repeat can only come after an answer, and an answer brings a `stop`.
- The hook forwards the prompt's first line, clamped to 200 characters. The
  tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a
  pasted file would otherwise cost the whole event; and a needle spanning a line
  break matches no single row.

No protocol change: the prompt rides in the OSC the hook already sent, and an
older client ignores the field.

* refactor(panel): drop the Info panel's agent row

It said `Claude Code · working` behind a status dot — the same name and the
same dot the tab chip and its sidebar row were already wearing, restated two
panels away from either of them. The CONVERSATION section that now sits under
it says what the agent is doing in a form the row never could: which turns
there were, which one is still running, and a way back to each.

`InfoValue::Agent` and `status_pip` went with it — the dot was the row's only
caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three
status labels stay: the tray menu still names them.

`Tab::agent_row` stays too. `agent_status` is that pair's status and the tab
strip's badge reads it, which is the one-leaf rule #543 put there.
2026-08-20 21:56:34 +08:00
l0ng-ai 8a950a343b Say what this platform does, not what macOS does (#700)
* fix(i18n): say what this platform does, not what macOS does

Four pieces of user-facing wording described macOS as if it were the only
platform they were read on, in all three languages at once — each
translation had faithfully carried the English text's assumption across.

- Copy on select claimed "no ⌘C needed" everywhere. Off macOS the binding
  is Ctrl+Shift+C, so the sentence named a key that copies nothing.
- The blur switch was labelled "(macOS)" on a row Linux also renders and
  also honors. Windows gets the backdrop picker instead, so the label was
  wrong for every reader it had. It now says which compositors deliver it,
  because gpui's X11 backend does no blur at all and Wayland only does
  when the compositor offers a blur manager.
- X11 forwarding named XQuartz as the only prerequisite anyone could have;
  Windows needs an X server of its own and Linux needs nothing.
- The Explorer verbs were string literals, so a Chinese or Japanese
  install got English context-menu entries for the life of the install.

The Explorer labels are the one string in the product that outlives the
process that wrote it: Explorer reads them from the registry, not from
tty7. Registration now sets the locale before building the entries (that
process returns before the GUI path's set_locale ever runs), and a
language change in Settings restates them. Only keys that already exist
are rewritten — offering the menu is the installer's checkbox and
declining it is the user's, and changing a language must never be what
puts the verbs back.

* docs(settings): the blur description no longer says what this comment quotes

* fix(config): restate the Explorer verbs when a hand-edited language changes
2026-08-20 20:51:36 +08:00
webdevandl0ng-ai 51b0fe64b9 fix(linux): stop the compositor framing a window that draws its own title bar (#679) (#683)
* fix(linux): stop the compositor framing a window that draws its own title bar (#679)

tty7 paints its own title bar through gpui-component's TitleBar, and the
WindowOptions it opens with say as much (appears_transparent) — but say
nothing about decorations. gpui reads a missing window_decorations as
WindowDecorations::Server and, on Wayland, sends
zxdg_toplevel_decoration_v1.set_mode(server_side) for the toplevel, so a
compositor that honours it draws a second title bar and border around the
one the app already has. window_options() now asks for
WindowDecorations::Client, which is what Zed defaults to (its
window_decorations setting, overridable by ZED_WINDOW_DECORATIONS).

Nothing new is painted for it. gpui-component's Root already wraps the
window in window_border() — bordered defaults to true and tty7's root
never turns it off — which under Decorations::Client draws the 1px frame,
the 12px shadow, the resize hit bands and the right-click window menu, and
tells gpui its inset through set_client_inset; under Decorations::Server it
degrades to a plain div. The request was the only piece missing.

The field is set without a cfg, unlike the icon beside it: the icon is
gated because the PNG behind it is only decoded on Linux, while this is a
plain enum that costs nothing elsewhere. request_decorations is an empty
default on the PlatformWindow trait that neither the macOS nor the Windows
backend overrides, so both keep answering Decorations::Server and the
window there is unchanged. X11 turns the request into _MOTIF_WM_HINTS and
falls back to server-side on its own when no compositor is running, so a
bare X session still gets a window-manager frame — and a reparenting WM
under a compositor, which today is told in the same hints to decorate,
gets the same fix as Wayland.

Client-side decorations bring one follow-on that Zed hit too
(ca9cee85e1, "linux: Fix non-maximized Zed windows growing larger across
sessions", #22301), and the two Linux backends want opposite answers to
it. The bounds tty7 remembers go back in through
WindowOptions::window_bounds, which every backend reads as the outer
rectangle. On Wayland under client decorations the outer rectangle is
the surface, shadow included, and the compositor's first sized configure
adds the inset back onto whatever was asked for (compute_outer_size) —
so saving outer and reopening at it grew the window by twice the shadow
per launch, and saving inner pre-deflates by exactly what the configure
re-inflates. X11 never re-inflates: it creates the window at the
requested rectangle verbatim, and its inner_window_bounds also shifts
the origin by the inset, so saving inner there would shrink the window
and walk it down-right by the shadow on every launch wherever the
request is honoured (a compositor plus _GTK_FRAME_EXTENTS — GNOME on
Xorg, Plasma X11). A window_bounds_to_remember helper therefore saves
inner on Wayland and outer everywhere else, told apart by
cx.compositor_name(); macOS and Windows report no inset, so the two are
the same there. WindowState round-trips unchanged.

The one place that hardcoded the window's corner follows the frame: the
pane-to-tab-strip drop band was a rectangle from (0, 0) to the title
bar's height, which under client decorations is the shadow strip plus
the top of the bar, missing its lower third. It now starts at
window_paddings(window), which is zero under server decorations, so
nothing moves off CSD.

A test pins the request: window_options() must answer Some(Client), and
its title bar must be the transparent one the request stands in for.

Not verified here, with no Linux session to run in: that the reporter's
compositor honours the mode switch (the protocol lets it refuse), how the
12px shadow reads against the shipped themes, the edges of a maximized or
tiled window, where gpui-component drops the padding on the tiled sides,
and one quit-and-relaunch on X11 under a compositor to see the remembered
size hold. The app.rs change is untestable in principle: gpui's
TestWindow overrides neither inner_window_bounds nor the decorations, so
inner and outer are one rectangle in every test. FreeBSD runs the same
backends with gpui-component's shadow at zero; unexercised.

* fix(linux): remember the inner window bounds on X11 too, not just Wayland

The bounds tty7 remembers were saved as the *outer* rectangle everywhere
but Wayland, on the reading that X11 creates its window at the requested
rectangle verbatim and never puts the shadow back on. That reading is
wrong, and it reintroduces on X11 exactly the bug the split was written
to avoid on Wayland.

gpui only turns client-side decorations on for X11 when a compositor is
present *and* the window manager advertises _GTK_FRAME_EXTENTS
(client_side_decorations_supported in x11/client.rs). A window manager
that advertises that atom is one that honours it — it keeps the visible
frame put and treats the extents as shadow outside it — so a window
reopened at its outer rectangle comes back one shadow larger on each
side, every launch. That is what Zed measured: ca9cee85e1 ("linux: Fix
non-maximized Zed windows growing larger across sessions", #22301), the
commit this code cites, took all of its before/after numbers on X11
(+20px per session) and fixed both backends with a single unconditional
inner_window_bounds(). Zed still reads it unconditionally today, at the
rev pinned here.

So drop the compositor_name() branch and save the inner rectangle on
every platform, as Zed does. It is a no-op wherever there is no inset to
strip: inner_window_bounds defaults to window_bounds on the
PlatformWindow trait and neither the macOS nor the Windows backend nor
gpui's TestWindow overrides it, and on X11 without a compositor
window_decorations() answers Server, so the window border never calls
set_client_inset and last_insets stays [0, 0, 0, 0].

Also lift the tab strip's drop band out of the render path into
strip_band(), so the padding arithmetic can be tested without a window:
the viewport measures the whole surface, shadow included, so the band
loses one padding at each end rather than one twice over or none at all.
It clamps at zero now — a surface narrower than its own shadow is only
reachable mid-resize, but a negative width would hand Bounds::contains a
rectangle that is inside out.

Three tests: the band is unmoved when the frame reports no padding (macOS,
Windows, a bare X session), it reaches the far edge of the frame rather
than of the surface when it does, and it collapses instead of inverting.
window_bounds_to_remember stays untested on purpose — TestWindow makes
inner and outer the same rectangle, so any assertion about it would only
restate the call.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 10:14:49 +08:00
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 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 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 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-ai 32029f4733 fix(ssh): a typed port of 22 outranks an alias that sets its own
Quick connect merged the typed line over a matching `~/.ssh/config` alias
by asking whether the parsed port was 22. Port 22 is also what the parser
fills in when nothing named a port, so `myalias -p 22` against an alias
carrying `Port 2222` read as silence and connected to 2222. Checked
against the reference: `ssh -G -F cfg -p 22 myalias` reports port 22, the
command line outranking the config file.

The parser already knew — it tracks `Option<u16>` and only collapses it at
the end — so the answer is carried out as `port_given` rather than
recovered from a value that cannot hold it.

The merge itself moves out of the event handler into
`merge_typed_ssh_over_alias`, which is what let this be tested at all: the
rule is now stated in one place and covered for the typed `:22` spelling,
the silent case, an ordinary non-default port, and no alias at all.

The other `port == 22` comparisons are display code deciding whether to
print `:22`, which is correct and unchanged.
2026-08-16 17:19:09 +08:00
l0ng-ai 89426fb5c4 fix(links): keep the path when a template token holds an absent value
`link_file_command` dropped a whole token whose value was missing. That is
right for `--line={line}`, where the flag means nothing without it, but the
same rule threw the file away in `code --goto {path}:{line}:{column}` —
which is VS Code's own spelling, and one of the three examples the docs
offer, `zed {path}:{line}` being another. Clicking a link that carried no
line number ran `code --goto` with no file and opened nothing, silently.

A token that has already produced the path now keeps it and stops there,
taking the separator that introduced the absent value with it so the
argument ends at the path rather than at a bare `:`. Tokens with no path in
them still go entirely, so the documented flag behaviour is unchanged.

This edge was noted in a test that asserted it as a "sharp edge" rather
than fixing it; that test now asserts the file opens.
2026-08-16 17:05:52 +08:00
l0ng-ai eaca1e50d8 fix(keymap): refuse a tmux prefix that cannot carry a sequence
Every preset binding is built as `<prefix> <key>`, and nothing checked the
prefix. `"prefix": ""` in a hand-edited config therefore produced no
sequence at all — it bound bare `c`, `x`, `z`, `n`, `o` and the digits
directly onto NewTab, CloseActiveTab, ToggleMaximizePane and the rest, so
typing an `x` in the terminal closed the tab. `"c"` and `"shift-c"` are
the same trap one step removed: the letter starts a sequence and swallows
the next keystroke, and shift-c is simply how a capital C is typed.

`preset_prefix` now requires one chord carrying a non-shift modifier and
falls back to the default otherwise, naming the refused prefix in the log.
That also catches `"C-a"` — tmux's own spelling, which gpui does not parse
— which previously slipped through to `action_bindings` and dismantled the
preset one binding at a time, warning about each key rather than about the
prefix that caused it.

The GUI only ever offered Ctrl-B and Ctrl-A, so this is reachable through
config.json, which the docs describe. Those docs also promised a **Prefix**
field to type into; it is a two-option row, and now says so.
2026-08-16 17:01:50 +08:00
l0ng-ai c793b17632 test(i18n): derive the plural-branch guard instead of listing the keys
The guard walked a hand-written array of 23 keys, so a key given a plural
or select branch after that array was written was checked by nobody — it
had drifted four behind (AppTabsNotRestored, LaunchWorkspacesLeftRunning,
SearchTabs, SftpReplaceBody). This is the same hand-maintained-copy
problem the key list itself was rid of, and the same answer: walk
`L10nKey::ALL` and treat any key the English catalogue gives a branch to
as one every locale must spell out.

All four turned out to be translated, so nothing was broken for a reader;
what was broken was the check. Deleting zh's "one" branch for one of them
now fails the test, where before it passed.

The smoke pass also runs in each supported locale rather than only the
one the test happened to be left in.
2026-08-16 16:54:46 +08:00
l0ng-ai ff6a13290d fix(sftp): floor the transfer percentage, and pin the test config dir
The percentage label is formatted `{:.0}`, which rounds: a transfer at
99.6% printed "100%" beside a row that still said Running. Flooring only
moves that case — a transfer whose bytes really are all through still
reads 100. The same expression was inline in two render functions, the
second carrying a comment that said only "for the reason above"; both now
call one `transfer_pct` that a test covers.

The tests in this module never pinned the config dir, so `cargo test
ui::sftp` on its own failed on the guard that keeps a test out of the
real `~/.config/tty7`. They passed in a full run only because some other
test pinned it first — `set_config_dir` is first-wins. The harness pins
it now; the other test modules were checked and already do.
2026-08-16 16:50:45 +08:00
l0ng-ai 9723ff3dd8 fix(ui): a size one byte under a unit no longer reads 1024.0K
1 MiB - 1 byte  ->  "1024.0K"   in the SFTP listing
                    ->  "1024.0 KiB" while installing a remote server

Both loops asked whether the value had reached the next unit, then printed
it rounded to one decimal. 1023.999 has not reached 1024, and prints as
`1024.0` -- a number that disagrees with the unit beside it, next to a
`1.0M` one byte along.

The comparison is now against what will be shown rather than what is held,
at the same one decimal the format uses. The exact boundaries are
unchanged, and `1023.9K` still prints as itself: the carry only moves a
value that would have displayed as `1024.0`.

The two existing tests grew the boundary case rather than gaining
siblings, since each already walks its own formatter through the units.

`update.rs`'s `human_bytes` is left alone -- it prints megabytes and never
advances a unit, so it has no boundary to get wrong. That the three of
them format differently at all is deliberate: a size column is tight, a
progress line is not.
2026-08-16 16:37:22 +08:00
l0ng-ai 7b3968714b docs(completion): say why a quoted prefix completes nothing
`cat "My Doc` offers nothing: the word starts after the space, so it is
`"My Doc`, and no file begins with a quotation mark.

That is a gap rather than a decision, and the comment says so -- but it
also says why moving this boundary alone would make things worse.
Accepting a candidate goes through `shell_escape_path`, which backslashes
a space; inside double quotes a shell reads `\ ` as a literal backslash,
so completing `"My Doc` and inserting `My\ Documents` would build a path
that does not exist. The boundary and the escaping have to move together.

Written after checking what does work, so the note does not overstate the
gap: `~/Lib` completes to `~/Library` and keeps the tilde, a name with a
space or an apostrophe completes and is escaped correctly on the way in
(`cd My` + `My Documents` gives `cd My\ Documents/`), and `./wi` keeps its
prefix.
2026-08-16 16:19:11 +08:00
l0ng-ai beb643f8c3 fix(completion): complete a command inside $( ) as a command
echo $(gre   ->  nothing
    ls | gre     ->  grep

`segment_start` has treated `(` as the start of a new command all along --
that is what makes `ls | gre` offer `grep` -- but the word was cut on
whitespace alone, so `$(gre` was a single word and the parenthesis never
reached the check. The completion went looking for a file called `$(gre`,
found none, and offered nothing.

The word now starts at the later of the two boundaries. Taking the maximum
rather than teaching the backward walk about `(` is what keeps quoting
right: in `echo "a(b` the parenthesis is literal, and only the forward
scan tracks quotes well enough to know that.

Backticks are deliberately left alone. `(` has a distinct `)`, so treating
it as a start is unambiguous; a backtick is its own closing mark, and the
same rule would make `echo `date` fo` look like a command position for
`fo`, which is worse than offering nothing.

So are `sudo gre`, `if gre` and `for f in *; do gre`, which offer files
today. Those want a notion of wrapper commands and shell keywords that
this does not have, and adding one is a feature rather than a repair.

The test drives `complete` rather than the two boundary helpers: the
helpers were each right on their own, and what was wrong was which the
caller used -- a test on them passes either way, which I found by writing
that test first and watching the mutation survive it.
2026-08-16 16:14:01 +08:00
l0ng-ai ad4954ec77 docs(ssh): record why Match blocks are skipped, where they are skipped
`Match` is dropped along with everything under it, and nothing in the code
said whether that was a decision or an oversight. It is a decision --
`docs/remote/ssh.mdx` lists "No `Match` or `canonicalize*` directives"
under what is not supported -- and it took a run of `ssh -G` against a
config to establish that, which is a reason for the next reader not to
have to.

The comment also says why reading them is more than parsing them:
`Match host` tests the *resolved* hostname, so it needs the pass it sits
in; `Match exec` runs a command, which importing a config file should not
do; `Match canonical` wants canonicalisation, which is on the same
unsupported list.

The test holds the documented shape: `ssh -G` answers `user alice` and
`port 2222` for that config, tty7 answers the defaults, and the profile is
still imported from its `Host` block. It is a limitation rather than a
behaviour, so it is worth a test only because failing it means the page
has to change in the same commit.
2026-08-16 15:32:07 +08:00
l0ng-ai b6e9609e27 fix(ssh): a relative Include means ~/.ssh, wherever the line sits
ssh_config(5) puts a relative include "in ~/.ssh if included in a user
configuration file". tty7 resolved it against the directory of the file
the line appeared in, which is the same place for `~/.ssh/config` and a
different one everywhere below it: `Include extra.conf` inside
`~/.ssh/conf.d/x.conf` reached `~/.ssh/conf.d/extra.conf` here and
`~/.ssh/extra.conf` in ssh.

Checked against ssh rather than the page alone. Given a config with `-F`,
a host defined directly in it resolves, and one behind
`Include conf.d/main.conf` does not -- ssh looked under ~/.ssh, not beside
the file it was reading.

Every existing test passes unchanged, because they all put their config at
`<root>/.ssh/config` where the two rules agree; that is also why nothing
caught this. One of them was called
`follows_includes_relative_to_config_file`, which named the rule that
turned out to be wrong, so it now says `..._to_the_ssh_directory`.

Note for anyone extending this: the system file's relative includes resolve
against /etc/ssh instead, and nothing here opens one.
2026-08-16 15:27:02 +08:00
l0ng-ai a0c5163c58 fix(ssh): read the Host=name spelling OpenSSH accepts
ssh_config(5) says a keyword and its argument "may be separated by
whitespace or by whitespace and exactly one `=`". tty7 split on whitespace
alone, so a line with none -- `Host=eqhost`, `HostName=example.com` --
had no separator to find and was dropped whole.

Checked against ssh itself before touching anything. On the same file:

    $ ssh -F config -G eqhost
    user alice
    hostname example.com

    tty7: imported ["spacehost"], alias_still_resolves("eqhost") = false

A host ssh resolves and tty7 calls gone is not a cosmetic disagreement:
that is what parks a workspace with nothing to say why, which is the whole
subject of #525 a few lines above.

Only the separator moved. An `=` inside an argument -- `SetEnv FOO=bar`,
a `ProxyCommand` with `-x host:port` -- is past the split and untouched,
which the test holds alongside the spaced form.

Found by reading the parser against the manual page it implements, then
asking `ssh -G` who was right.
2026-08-16 15:20:17 +08:00
l0ng-ai 364dbeedbd test(themes): keep every built-in on the page that lists them
The themes page names all nine one by one and its description counts them,
so a tenth added to `BUILTINS` would leave the page wrong twice over: a
theme in the picker that nobody wrote down, under a sentence claiming
nine. Themes are the kind of thing that gets added.

Matched on display names, because the page is written for someone reading
the picker — `rose_pine` is "Rosé Pine" there, accent and all. Dropping
Harbor from the page fails it.

The check that prompted this found nothing wrong anywhere else, which is
worth recording: all nine themes, all eighteen detected agents, and all
seven agents with installable hooks are already named on their pages.
Two of those looked like gaps first time round and were mine, not the
docs': `harbor` matched nothing because the page says "Harbor", and
`OhMyPi` because it says "Oh My Pi". A name-matching sweep is only as good
as its idea of how names are written.
2026-08-16 15:14:53 +08:00
l0ng-ai cb4010344e fix(terminal): a tab stop or a mode change no longer parks the cursor
hide, move to 9;9, show            -> cursor at 9;9
    hide, move to 9;9, CSI g, show     -> cursor back at 6;4

The scanner sorts CSI finals into ones that move the cursor and ones that
leave it alone, and treats everything else as a paint. A paint clears
`last_was_move`, which makes the following show "parked" -- the cursor is
put back on the cell it was hidden on, because a frame that ended on an
erase left it wherever the erase finished.

Terminal settings are on neither list and were being counted as paints.
Clearing a tab stop, or setting an ANSI mode, between a frame's last move
and its show therefore dragged the cursor off the cell the frame had
chosen. Demonstrated above before changing anything, with `CSI 4 l` doing
the same.

Which finals belong on the list came from `vte`'s own dispatch rather than
from the spec: `g` clears tab stops, `h`/`l` set and unset ANSI modes --
their `?` forms return earlier, so this cannot disturb `?25h`/`?25l` -- and
`k` is SCP. All four reach the emulator without touching the grid.

`p` stays where it is, which is worth writing down because it looks wrong:
`CSI ! p` is DECSTR and homes the cursor in the spec, but `vte` implements
only the `$p` and `?$p` forms, so DECSTR never reaches the grid here.
Classifying it as a move would describe a terminal this is not.

The test holds all four finals against the position the frame chose, and
keeps the erase case parking, which is what the pairing is for.
2026-08-16 14:58:16 +08:00
l0ng-ai d6c3ddddf3 docs(palette): note which titles can reach the exact-match bonus
`fuzzy_score` adds 120 when the folded haystack equals the needle. Only a
single-word title can: the needle drops whitespace so `newtab` and
`new tab` both match, while the haystack keeps it, so `Settings` can equal
its query and `New Tab` never can.

Nothing is wrong with the ranking -- typing any of twenty overlapping
titles in full ranks that title first, `New Tab` over `New Worktree Tab`
and `Rename Tab` over `Reopen Closed Tab`. The word-start and run bonuses
carry it without the 120. But that is not visible from the branch, and
somebody tuning either bonus deserves to know what is holding the case up.

I wrote a test for the whole-list outcome first and threw it away: it
survived weakening the word-start bonus from 12 to 1, deleting the length
penalty, the run bonus, the prefix bonus and the exact bonus. Typing a
complete title matches from position 0 with nothing skipped, which
dominates under any scoring this file is likely to have, so the test
asserted something no plausible change breaks. The pairwise tests beside
it -- initials over scattered letters, exact over mid-string -- are the
ones with teeth.
2026-08-16 13:47:47 +08:00
l0ng-ai bd937079bc fix(cmd-editor): a delete with nothing to delete no longer eats the redo
Undo a line, press Backspace at column 0, and the line could not be
brought back. Confirmed before changing anything: redo restores "abc"
normally, and returns "" once a no-op Backspace sits between the undo and
the redo.

Every delete took its checkpoint before asking whether it had anything to
delete, and `checkpoint` clears the redo stack. So a keystroke that could
not change the line -- Backspace at column 0, Delete at the end, either
word-delete at its edge, either kill-to-edge already at that edge --
discarded the redo it had just earned. Six of them, all the same mistake.

Each now returns before the checkpoint when there is nothing to do. The
two predicates differ and are worth keeping apart: Backspace and Delete
eat a selection wherever the cursor sits, so they ask about one, while the
kill-to-edge family does not touch a selection and is decided by the
cursor alone.

The test walks all six through the same undo-then-no-op-then-redo
sequence, and a second one checks the guards do not block a delete that
does have work, including a selection deleted by Backspace at column 0 --
which is the case a guard written as `cursor == 0` alone would break.
2026-08-16 13:13:03 +08:00
l0ng-ai 81b405efa6 fix(terminal): stop offering a bare scheme as a clickable link
`https://例え.jp` in a pane came back as the link `https://`. Clicking it
opens nowhere.

`is_url_char` is ASCII on purpose -- CJK runs together with whatever
follows, so accepting non-ASCII would let `https://example.com見て`
swallow the rest of the sentence -- and an internationalised domain
therefore truncates at its first character. What was left was the scheme,
and it was handed back as a link anyway.

The ASCII policy stays; only the hostless leftover is refused. A reader
with such a URL now gets no link rather than a broken one, which is the
honest answer, and the limitation is written down where the decision is
made rather than being an accident of `is_ascii_alphanumeric`.

Found by probing the edges rather than reading them. Seven other cases
were already right and are worth recording: a trailing full stop, wrapping
parens and angle brackets are stripped, while balanced parens inside a
path, a comma mid-path and a query string with `&` are kept.
2026-08-16 13:01:40 +08:00