Commit Graph
100 Commits
Author SHA1 Message Date
l0ng-ai e7d04066d2 fix(capture): --plain no longer answers with a fraction of the pane
`capture` offers "two independent choices": how much (`--scrollback`) and
in what form (`--plain`). They were not independent. Every `--plain` grid
was built with `Config::default()`, whose 10,000 scrolling lines were
justified in a comment as "the daemon ring's order of magnitude".

The ring is capped in *bytes* — 8 MiB — which at ordinary line lengths is
nearer 100,000 lines. So the plain form silently dropped most of what the
raw form returned, and `--scrollback` could not bring it back. On a pane
that had printed 120,000 lines:

    capture           8,388,609 bytes, from line 30,764   (the whole ring)
    capture --plain     932,651 bytes, from line 109,973
    capture --scrollback --plain   identical to the above

Nine tenths of the pane missing, with nothing said. `--plain` is the form
the orchestration docs reach for (`tty7 wait %3 && tty7 capture %3
--plain`), so an agent reading the end of a long build log got the tail
and no reason to doubt it had the rest.

The grid is now sized from the segment it has to replay. Counting bytes
per column is not enough and the difference is the bug in miniature: a
line shorter than the pane is wide still costs a whole row, so dividing by
the width under-counts exactly when lines are short, which is most output
— that estimate alone recovered sixty thousand lines and still stopped
twenty thousand short. One row per newline plus one per screenful of wrap
is the bound that holds. Both forms now begin at line 30,764.

Over-estimating is free: alacritty grows its history as lines arrive
rather than allocating up front, and the daemon's ring bounds the whole
thing. Measured: an ordinary capture is unchanged at 8 MB resident and
0.3s; a full 8 MiB ring costs 343 MB for the 2.5s it takes.

The regression test uses short lines for the reason above, and fails
against the old fixed 10k grid.
2026-08-22 23:51:47 +08:00
l0ng-ai 449d38e538 docs(wait): say that an unknown pane answers exit, and pin it
`tty7 wait` is the one address-taking verb that does not refuse a pane the
server has no record of. `capture`, `procs`, `send` and `pane close` all
exit 1 on the same address; `wait` answers `exit`, `matched: true`,
`stale: true`, and exits 0.

That is the right behaviour and must not change. The server forgets a
pane once it is reaped, so "the worker finished and was cleaned up" and
"that id never existed" are one question to it — measured, not assumed: a
pane that really ran and exited comes back byte-identical to `%9999`,
and `pane ls --all` has forgotten both. Refusing would break the first
case, which is the ordinary end of an orchestration: you wait on work
that may already be over.

What was missing is that nobody had written it down. Neither the CLI
reference nor the orchestration page said what an unknown pane does, and
both define `exit` as "the pane is gone" — true of a typo, but not what a
reader takes from it when every neighbouring verb errors. An orchestrator
that trusts a bare `wait` as proof the work happened gets an instant
success from a stale id and reads an empty capture as "no output".

So both pages say it, and a test pins it. Without the test this is an
accident that reads like a bug, and the obvious "fix" — make it error like
its siblings — would silently break waiting on finished work.
2026-08-22 23:41:08 +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 024d368925 docs(skill): restructure the agent skill around a delegation playbook (#702)
* docs(skill): restructure around a delegation playbook

SKILL.md becomes a slim routing layer: a what-are-you-here-to-do section up
front, the pane/run/wait primitives, and four delegation rules that survive
even when the reference is skipped. Everything specific to running another
agent moves to references/delegation.md, which adds what the old text never
had: per-worker git worktree isolation, a delivery contract collected through
git instead of screen scraping, a launch-verification checklist, a babysit
loop, and a fan-out harvest with short per-worker timeouts so one stuck
worker cannot stall the round. Also replaces the last remaining 'claude -p'
example (the fan-out one #699 missed) and keeps every snippet valid under
both bash 3.2 and zsh.

* docs(skill): un-deadlock the fan-out harvest loop

Fresh read of SKILL.md and references/delegation.md. Every internal anchor
resolves and the two files agree on the primitives; three things did not
hold up:

- The harvest loop passed `--changed`, which cannot work there. `wait`
  compares against the state standing when *that* wait began, so a worker
  that reached `done` while you were waiting on a different one is already
  in `done` when its own turn in the round comes up — refused, every round,
  forever. Each pane runs one turn, so a standing `done` is this turn's;
  drop the flag and note the one thing it was buying (a just-answered
  `waiting` worker needs to leave that state before it is requeued).
- The same loop folded `wait`'s exit 1 into its 124 branch, so a pane that
  died got requeued instead of reported — and requeued at full speed,
  since a dead pane answers immediately. Split the three codes.
- SKILL.md described `--plain` unwrapping "a line the shell wrapped at
  column 249" while two other passages state a pane is 120 columns.
  Say "at the pane's width", as references/commands.md already does.

No typos or grammar slips found. Every bash block in both files parses
under bash 3.2 and zsh.

* docs(skill): two failure modes from the playbook's first live run

Dogfooded the delegation playbook end to end (worker reviewing this very
file). Two failures it hit that the text did not cover:

- A turn aborted by an API error emits no turn boundary, so the status
  stands at 'working' forever and wait sleeps through it. Diagnose from
  the screen's error line; recover by telling the still-alive interactive
  session to continue.
- A short capture tail cuts off the spinner line and shows only the TUI's
  always-present input box, which reads as idle. Tail 15+ lines and read
  for the spinner; 'bottom looks like a prompt' is only evidence on a
  shell pane.
2026-08-20 21:02:11 +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
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 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 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 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-ai bcbdf51fed fix(cli): a server that closes the event stream is not a success
`tty7 events` blocks forever by contract, so returning at all means the
control connection went away with the server — and it returned 0. A reader
whose server stopped mid-run was told nothing had happened; the loop
consuming its lines simply stopped receiving any, with no exit code to
branch on. Verified against an isolated daemon: stopping the server ended
the stream and the CLI exited 0.

It exits 1 now and says so on stderr. Nothing extra goes to stdout, so a
reader parsing NDJSON is never handed a line of a shape it has not seen —
the same split the rest of the verbs use. An interrupted run is unaffected:
a signal takes the process rather than this path.
2026-08-16 17:47:52 +08:00
l0ng-ai c1b3a10561 fix(cli): print an event as a sentence, not a Rust debug dump
`tty7 events` rendered a layout delta with `{delta:?}`, so a line the docs
describe as an event arrived as a struct literal — internal field names,
`Some(..)` and all:

    workspace f5e0c172… layout: PaneFacts { pane: PaneRecord { id: 1,
    cwd: Some("/tmp"), title: "", osc_title: None, … } }

Debug output is not a format anyone should be reading, and it is certainly
not one to keep stable: renaming a field would silently rewrite what this
verb prints for whoever had started parsing it. All thirteen deltas now
have a line — `pane %1 is gone`, `tab 70e8e933 created at 0`, `renamed to
demo` — and the catch-all arm that dumped every remaining variant is gone
with them.

That arm covered `GitChunk`, whose `{:?}` prints a `Vec<u8>` one decimal
number per byte: a single chunk of a large diff would have arrived as pages
of them. It reports a byte count now.

The docs say the prose is for reading and `--json` is for parsing.

Found by running `tty7 events` against an isolated daemon while creating
and renaming a workspace.
2026-08-16 17:45:15 +08:00
l0ng-ai baeffd0274 fix(doctor): say when the inherited context names nothing here
`doctor` reported `$TTY7_WS` and `$TTY7_PANE` as "set (id)" without ever
asking whether the id resolves. A shell outlives the workspace it was
opened in, and one opened against another machine names an id this server
never had — a state the rest of the CLI already guards against, `run
--keep` in as many words. Doctor is the verb people reach for when the
address-taking verbs have started failing, and on that exact input it gave
the reassuring answer.

The rows now say GONE when the id names nothing on the server that
answered, and `--json` carries `workspace_gone`/`pane_gone` beside the
existing booleans rather than changing them. Both are absent when no
server answered: with no tree to check against, "not gone" would be a
claim rather than an answer.

Found by running `tty7 doctor` against an isolated daemon while the shell
still carried the real server's variables.
2026-08-16 17:39:04 +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 5fc97d5d06 docs(shell): put nushell on the page that lists integrated shells
The "Which shells" table named zsh, bash, fish and PowerShell. Nushell
has had the same treatment as the rest for as long as they have -- a
throwaway `config.nu` passed with `--config`, sourcing the user's own
back in -- and a nushell user reading that page concluded they got
nothing.

The row says how, including the part that makes it unlike the others:
`source` is parse-time in Nushell, so the path to your config is resolved
as the wrapper is written rather than checked when it runs.

The test reads the shells out of the injection dispatch, so a sixth has to
be written down before it passes; removing the new row fails it, naming
Nushell.

Found by checking a table against the thing it claims to describe, which
also cleared two nearby ones: `PATH_PROBED_SHELLS` holds twelve shells
against these five, and that difference is right -- probing PATH so a
shell can be chosen is not the same as having hooks for it, and ksh or
tcsh work fine without them.
2026-08-16 15:09:54 +08:00
l0ng-ai db275b7770 docs(cli): say that a newline inside send's TEXT presses Enter
$ tty7 send %1 "$(printf 'echo ONE\necho TWO')"
    ... echo ONE
    ONE
    ... echo TWO          <- typed, waiting

Correct for a verb whose first line is "types TEXT into the pane exactly
as a keyboard would" -- typing a newline is pressing Enter. But `--enter`
is documented right next to it as the way to submit, which reads as
though TEXT alone cannot, and an orchestrator passing along text it did
not write runs it a line at a time.

So `--help` and the reference now say it, with the shape it takes: the
text before the newline runs, and what follows is left typed at the
prompt it asked for. A test pins the bytes -- neither stripped nor split
into two writes -- so the pages and the behaviour cannot drift apart.

Found while feeding a pane input it does not expect. Three neighbours
came through that unchanged and are worth recording: invalid UTF-8 in
output becomes U+FFFD with correct recovery (`\xc3\x28` is one
replacement and a literal `(`), `capture --json` stays valid JSON over
it, and an OSC title carrying bad bytes is folded before storage so
`machine.json` stays parseable and the tab table stays aligned.
2026-08-16 13:33:46 +08:00
l0ng-ai cb07fd45e7 docs(cli): correct two claims about --json that scripts branch on
Both found by running the verbs rather than reading them, and one of them
is mine from four commits ago.

`--json` said two verbs still print JSON when they fail. There are three:
`doctor` prints its whole report on the exit-1 path, deliberately, because
an unreachable server is the finding and `tty7 doctor || alert` needs the
rows as well as the code. My earlier batch simply had not included it --
this time every verb that can fail was run with --json, and the other
seventeen do print nothing.

The `run` section was worse. It said a signal death reports
`exit_code_known: true` "because a status was read", and concluded that
`run` therefore cannot tell a command the OOM killer took from one that
exited 1 on its own. The flag is false, and it is exactly that
distinction:

    $ tty7 run --json -- sh -c 'kill -9 $$'
    {"pane":14,"exit":1,"exit_code_known":false,"kept":false}

`reported_exit_code` returns `None` for a status that spells itself
"Terminated by", `run` turns that into an exit 1 plus a note on stderr,
and an orchestrator can branch on the flag without reading the pane. The
page had been telling it to do the opposite.

So the daemon side now carries a comment saying why a signal is not
dressed up as `128 + signal` -- that convention belongs to the shell, and
inventing it here hands back a number the command never returned -- and a
test pins `None` for a signal against `Some(code)` for an exit, including
the "Terminated by" spelling the whole thing turns on.
2026-08-16 12:16:18 +08:00
l0ng-ai 74e8fdb5a7 docs: say what --json does when a verb fails
The flag's row promised "one JSON object on stdout" and stopped there.
Running the error paths, most verbs print nothing on stdout at all —
`capture %999`, `procs %999`, `tab close @99`, `ws rm nosuch`,
`tab rename @99 x` each exit 1 with the message on stderr and an empty
stdout. A reader that parses stdout without checking the exit code first
gets an empty string where it expected an object.

Two verbs deliberately do the opposite, because there the bad news is the
answer: `pane close` prints `failed[]` and `wait` prints the state it
gave up in. Both are already pinned by tests, so this only writes down
the rule they are exceptions to.

`run` looked like a third exception and is not: a child exiting nonzero
is the verb succeeding. Checked rather than assumed —
`run --json -- sh -c 'exit 3'` prints its object and exits 3 — and the
row now says so, since that is the case most likely to be misread as a
failure that kept its JSON.
2026-08-16 11:27:30 +08:00
l0ng-ai ed29cdce47 docs: a restored pane is a resized pane, as far as capture is concerned
`--scrollback`'s help said the flag makes no difference "for a
never-resized pane", and the reference said the same. Both are wrong for
the panes where the difference matters most.

I killed a daemon under a running GUI, watched it come back, and captured
a pane that had `RESTORE-MARKER-7788` in it. Plain `capture` answered with
two lines — the restore banner and a prompt — while `--scrollback` had the
marker and everything around it. Nothing had been resized.

`ReplayRing::seeded` ends with `resize(size)`, deliberately: the restored
screen is replayed at the size it was recorded at, and the new shell
writes at the size the pane came back as. When those differ the restored
screen is sealed into an earlier segment, and the default capture cannot
see it.

That is the agent-facing primitive answering "almost nothing" for a pane
that kept its screen, with the help explaining that this only happens
after a resize. Both now say restores count, and say what a plain capture
looks like when it does.

`resize` returns early on an unchanged size, so a pane that comes back the
same shape really does keep one segment — the test covers both sides of
that, which is the part the wording turns on.
2026-08-16 11:05:39 +08:00
l0ng-ai 9006e3ea17 docs: put the two files nobody opted into on the privacy page
"What is stored, and where" listed settings, keychain entries, scrollback
and shell history, and omitted both files that carry incidental personal
data:

  crash.log  written whenever tty7 panics — the hook is installed
             unconditionally, so nobody opts in. Time, version, panic
             message, backtrace. Capped at 256 KiB, never uploaded.
  tty7.log   only while TTY7_LOG or RUST_LOG is set, and genuinely absent
             otherwise. At debug it carries the directories and workspace
             names in each request.

A page that enumerates storage and leaves out the two files most likely
to end up attached to a bug report is answering the wrong question, so
both rows say what is in them and what to check before sending them on.

Noticed while reading a debug log for something else: a dependency had
written this machine's hostname and working directory into it.

"What leaves your machine" needed no change — nothing sends either file
anywhere, which is why each row says so.

The test lives next to the panic hook, where a change to what gets
written is a change someone is already making.
2026-08-16 10:50:30 +08:00
l0ng-ai 4ad5775fb4 docs(cli): the command reference does not cover every verb
Its description said "Every verb, its flags, and the JSON it emits". It
documents twelve of seventeen: `ws`, `tab`, `pane`, `machine` and
`server` are not on the page, not even in passing.

Writing the missing twenty-odd sections is a documentation project, not
a correction, and inventing them from the outside is how references
start disagreeing with the program. So the page now says what it is —
the verbs a script or an agent reaches for — and points at
`tty7 ws --help` and its siblings for the rest. Those are generated from
the definitions the CLI runs on, so unlike a second copy here they
cannot drift.

My first attempt at that paragraph claimed `ls`, `new` and `split` were
all shortcuts into the groups. Two are: `ls` is `ws ls` and `split` is
`pane split`, both said so in their own help. `new` is not — it makes a
workspace *and* its first tab, where `ws new` leaves it empty — so the
paragraph now says which is which.

`tty7 pane there`, which a grep of the overview turned up, is not a verb
either. It is the middle of "if you are not inside a tty7 pane there is
nothing to split".
2026-08-16 10:23:46 +08:00
l0ng-ai 418ebbf960 docs: the front page promised shells that survive a reboot
README, both languages, and the docs index said that after rebooting
the machine your shells are still running. They are not, and the
project's own agent documentation says so plainly:

    When the server goes away — a reboot, a crash, a deliberate
    restart — the shells go with it. Panes that were running an agent
    relaunch the conversation on restore.

That is the accurate version, and it is what I measured: killing the
GUI leaves the server and every pane alive, and restarting the server
takes panes from two to zero while the workspaces survive. So quitting
the app really does keep shells running — the front page was right
about that half — and a reboot rebuilds the layout around fresh shells
with agent conversations resumed.

The headline is no weaker for being true; "resumes your agent
conversations after a reboot" is the thing that is hard to build, and
it is the thing that actually happens.

Left alone, because they were already exact: "a pane lost to a reboot
relaunches the conversation", prompt history that "carries across
sessions and reboots" (it is on disk), and "session resume after a
reboot".
2026-08-16 10:10:11 +08:00
l0ng-ai 07790f589d docs(config): say that agent_commands needs the server restarted
Checking whether any setting is written but never read turned up one
field named nowhere outside `config.rs` — `agent_commands`. It is read,
through `agent_commands_cached`, which is the point: that is the only
setting behind a `OnceLock`, so a running server keeps the map it built
at startup.

Every other key here is picked up by a reload. This one is not, and
nothing about editing the file says so: a user adding `{"cc": "claude"}`
sees the wrapper go on being unrecognised and has no reason to suspect
the server rather than the spelling.

The cache is right — the map is read on every pane spawn, and the
alternative is `Config::load()` off disk each time a pane starts. So the
row says `tty7 server restart` and the function says why it costs that,
each pointing at the other.

All 77 fields are read; this was the only one worth a word.
2026-08-16 03:57:06 +08:00
l0ng-ai 99994553ef fix(config): hold side-panel widths to the widths they can be drawn at
#550 settled the rule: one range, defined where the value is validated,
so a config-legal number cannot be turned around by a widget's narrower
clamp. `sanitize_clamps_to_the_same_bounds_the_gui_steps_within` states
it and pins the font pair. The two side panels broke it.

`sanitize` clamped both widths to 100–2000, and
`docs/reference/configuration.mdx` published that range, while the
sidebar floored itself at 180 and the right panel at 216. So a
documented `sidebar_width: 120` was accepted by sanitize, kept in the
file, and drawn at 180 — the file said one thing and the window showed
another, with nothing to explain the difference.

The floors move to `core::config` beside the font bounds, and the two
widget constants are defined from them, which is the direction that
cannot drift: the widget cannot be narrowed without moving the floor
the file is validated against. Docs updated to the real numbers.

The *ceiling* is deliberately not shared. Both panels also cap against
the viewport, but a panel wider than its window is a different question
from a panel wider than the setting allows, and only the second belongs
in `sanitize`.
2026-08-16 01:14:04 +08:00
l0ng-ai 9710fb23e6 docs(cli): say what exit_code_known means, and what run cannot tell you
The field was listed in `run`'s JSON and nowhere explained, on the surface
agents parse. It says whether the server managed to read a status before it
stopped waiting; when it did not, `run` exits 1 as a stand-in and says so on
stderr, and the flag is the only way to tell that 1 from a real one.

A command killed by a signal also comes back as `exit: 1` -- not the 128+N a
shell reports -- and with `exit_code_known: true`, because a status really was
read. The two are byte-identical in the JSON, so an agent cannot tell a
command the OOM killer took from one that exited 1 by itself, and it should
not be left to find that out the hard way.

That last part is a limitation rather than a choice: the pty crate keeps the
signal in a private field with no accessor, and hands back a placeholder code
of 1 in its place. `success()` cannot separate the two either, since it is
false for both. Reporting the real 128+N needs the child reaped directly --
which the daemon already does for adopted panes, where it gets this right.

Found by running the commands, not by reading them.
2026-08-15 22:03:08 +08:00
l0ng-ai 3edcc3884a docs(cli): agent_status is never sent either — correcting yesterday's table
The previous commit documented the `events` schema and listed `agent_status` as
one of the kinds a listener sees. It is not: `ControlEvent::AgentStatus` has the
same shape of problem as `PaneExited`, which is how it slipped through — the
only two references anywhere are the round-trip test in its own file and the
formatter in `tty7-cli`, with no emitter.

Auditing all six protocol enums is what turned it up. `ControlRequest` (42
variants), `ClientMsg` (30) and `DaemonMsg` (28) are clean; every `LayoutDelta`
and every other `ControlEvent` has a real emitter — `Preempted` and
`LayoutResync` in `host::server`, `GuiOpen` beside them — which is exactly what
made these two look supported.

Agent status is reported the same way a pane exit is: a `layout` delta whose
`pane_facts` carries the pane, with `agent.status` on it (`AgentFacts.status`
rides on `PaneRecord`). The note now covers both, and points at `tty7 wait` and
`tty7 agents` as the supported way to ask about an agent, since neither is
built on this stream.

Both variants now carry the comment; the recommendation is unchanged — emit
them or remove them together with their `event_line` arms, but not in passing,
because it is a dialect change.

2951 tests pass.
2026-08-15 19:34:35 +08:00
l0ng-ai a4d0a10a6e docs(cli): events had no schema, and promised a pane-exit event nothing sends
`events` is the streaming interface an agent builds on, and it was the one verb
whose JSON shape the reference did not give — every other one lists its fields
exactly. Now it does: the externally tagged envelope, the four event kinds a
listener sees, and the thirteen `layout` delta kinds.

The prose also listed "pane exits" as an event type. There is no such event.
`ControlEvent::PaneExited` is defined, encodes, and has a line
`tty7 events` would print for it — and nothing anywhere constructs one outside
the round-trip tests in its own file. `AgentStatus`, `Preempted` and
`LayoutResync` have 2, 10 and 6 emitters respectively; this one has none. An
agent waiting for a pane-exit line waits for good.

What actually happens is checked against a running server: exit the shell in a
pane and exactly one event arrives — a `layout` delta carrying `pane_facts` for
that pane with `"live": false`, and the pane is gone from `pane ls --all`. The
docs now say to watch for that, in a note that says outright there is no
pane-exit line whatever the wire protocol's shape suggests.

The variant keeps a comment saying the same thing, and that resolving it means
either emitting it where the pane is reaped or removing it along with the
`event_line` arm — not something to do in passing, since taking a variant out
of a wire enum is a dialect change.

2951 tests pass.
2026-08-15 19:30:16 +08:00
l0ng-ai 27c0c6c399 docs(cli): --orphans kills a run that is still going, not just an interrupted one
The warning on `pane close --orphans` said an orphan "can still be doing real
work — an interrupted `run` leaves the command running". True, and it stops one
step short of the case that costs someone work.

A `run` that is executing *right now* is an orphan too. `run --ws W` stamps its
pane with `W` as the owner and files it into no tab until `--keep` does, so for
the whole length of the command the pane reads exactly like a leftover.
Confirmed against a live server: `tty7 run --ws W -- sleep 45` shows up as
`orphan=true, owner=<W>`, the same shape a leaked pane has, and
`pane close --orphans` duly reported `closed 2 panes` — one of which was the
running command.

So the old advice — "look at `pane ls --all` first" — cannot be followed:
nothing in that listing tells the two apart. The docs now say that, and say
what to do instead (close by id when anything might be running).

This also rules out the reaper people will reach for when they meet the pane
leak in `ui::tree_sync`: the daemon's own `spawn_orphan_sweep` already computes
this exact set and deliberately only reports it, and an in-flight `run` is why
acting on it would be wrong. `orphan_panes` now carries that reasoning.

The distinction that would work is whether a client is still attached — an
interrupted `run` has none, a running one does. The daemon knows and
`PaneInfo` does not say; adding the field is backward compatible, since every
other field on it is already `#[serde(default)]`, but it is a wire change and
wants more than a doc pass.

2951 tests pass.
2026-08-15 19:24:21 +08:00
l0ng-ai 50b1d4a455 fix(cli): procs on a pane that does not exist is an error, not an idle pane
The daemon answers a pane it is not running exactly as it answers an idle one:
`registry.get(pane_id)` misses and the reply is an empty `PaneProcs`. So

    tty7 procs %999

printed `nothing running in this pane` and exited 0 for a pane that has never
existed. Every other verb taking a `%PANE` says when the pane is not there, and
an agent reading this one could not tell the two apart — which is the whole
point of the machine-readable half.

Checked against the registry rather than the workspace tree, because a pane no
workspace holds is still a pane the server runs and still worth reporting on;
that is exactly what `pane ls --all` surfaces it for. Verified live: a real
pane and an orphaned pane both still answer with exit 0, and %999 now exits 1
with "no pane %999 on this machine — `tty7 pane ls --all` lists them".

Asked only when the answer came back empty, so a pane with anything running in
it still costs one request.

One existing test needed the mock's registry seeded alongside its machine tree.
That is the fixture becoming faithful rather than the check being loosened: a
server running the pane its tree names is what the real pair look like, and the
mock had the tree without the registry.

2951 tests pass.
2026-08-15 19:04:59 +08:00
l0ng-ai a5229e18fa docs(cli): say what attach, ATTACHED and tab move INDEX actually do
Found by driving a dev instance with the CLI rather than by reading, which is
the only way any of these would have surfaced.

`ws attach` is documented as "become its controlling client", and the CLI
cannot be one: the claim belongs to the connection that made it and the
connection ends when the command does, so `tty7 ls` reads unattached again the
moment it returns. Its real and only lasting effect is displacing whoever held
it — which is exactly what the human output says (`took over from HOST`) and
what the docs never did.

`ATTACHED` said it names "a GUI window, or another client". A GUI only claims a
workspace it is showing as a *remote* one; a window on a workspace of its own
machine claims nothing, because there is no second client to arbitrate against.
So with a window sitting on it `tty7 ls` prints `-`, and the column read as
"nothing has this open" when it means "no remote client holds this".

`tab move @TAB INDEX` never said which end `INDEX` counts from. It is 0-based —
beside an `@N` address that is 1-based, on the same command line — so
`tab move @1 2` moves the first tab to the third slot. Verified against a
running server: `@1 0` leaves it, `@1 1` puts it second, past the end clamps to
last the way `split --ratio` already documents. `to` in the JSON echoes the
number you asked for rather than where the tab landed, which is worth saying
since the clamp makes those differ.

Also records why `WorkspaceDetach` throws away the one thing it computes: the
reply has always been `Unit`, and the dialect is spoken to whatever build was
pushed to a remote machine, so widening it to `Bool` would break every server
already out there.

2932 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

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

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

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

Two things in the machine tree had to follow:

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

Closes #621

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 17:05:34 +08:00
webdev 422808191d feat(sidebar): group a tab by its folder when its cwd is not a repo (#631)
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 14:46:22 +08:00
l0ng-ai 1424da0891 fix: nine UX fixes across the diff overlay, layout, settings and pane spawn (#623)
Found by driving a dev instance and measuring what an idle window costs.

Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.

The rest:

- The tab sidebar and the right panel each capped themselves at half the
  window and knew nothing about the other, so together they could take
  all of it — 260 points of terminal on a 720-point window. Both now cap
  at whichever binds harder: half the window, or what is left after the
  terminal's floor and the other panel's floor. The same cap bounds the
  drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
  `git diff --numstat HEAD`; an unstaged or staged patch answers a
  smaller question, so opening an untracked file from the Source Control
  panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
  focused could not be left — the breadcrumb looks the path up in
  `files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
  loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
  `/private/tmp/x` in every tab opened from the first. `PWD` carries it,
  and POSIX has the shell discard a `PWD` that names the wrong
  directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
  generated from the binding table rather than the static index — so
  searching for a feature finds its shortcut, and the page filters to
  the matches. Closes #444.
- The settings reading column is centred rather than pinned to the nav:
  on a window as wide as the display it was made for, 640 points of
  settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
  carry — it opens a form asking for a name and a host.

Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
2026-08-14 08:42:29 +08:00
Hongwei Qinandl0ng-ai a2d53a9597 fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594)

The group-level Discard prompt asked to "discard every change in this
repository", but discard_all_ops has only ever swept unstaged edits and
untracked files — staged changes survive, as the function's own comment
notes. Users confirmed under one belief and the code kept another.

Narrow the prompt to the operation's real footprint, in all three
languages.

* fix(scm): keep the amend toggle when its confirmation is cancelled (#595)

scm_commit cleared scm.amend when Commit was pressed, before the
"rewrite the last commit?" prompt. Answering Cancel returned to a panel
whose amend mode had silently been dropped, so the next Commit created a
brand-new commit — exactly what the user had just declined to risk.

The toggle now clears where scm.committing arms, at dispatch in
run_git_op, extending the rule the armed flag already followed: a
cancelled confirmation leaves nothing behind.

* fix(cli): answer a wait timeout in the success path's JSON shape (#589)

The 124 branch returned {pane,status,timed_out} while a finished wait
returns {pane,status,matched,stale,activity,message,session_id} — so the
one branch a consumer writes error handling for was the one missing its
fields. The timeout now carries the full shape plus timed_out, and
reference.mdx documents the schema and the flag.

* fix(cli): report a failed wait on stderr, even under -q (#590)

wait's failures are structured exits (124, or 1 when the pane died
first), so they never passed through the anyhow path whose eprintln is
the only thing quiet mode cannot silence — contradicting the documented
"errors still go to stderr". Both exits now print their headline to
stderr, the discipline pane close already established.

* docs(cli): describe owner as the workspace that may attach (#591)

commands.md still claimed the CLI stamps a literal "tty7-cli" owner on
the panes it spawns — the behaviour the orphan-workspaces work removed,
because an owner names the workspace allowed to attach and a stranger's
stamp got the panes respawned. Every spawn path now writes the workspace
id, or nothing while the pane is still unfiled. Bring commands.md in
line with reference.mdx, and note the absent case in both.

* docs(cli): close five contract drifts between the tables and the code (#592)

- The key tables listed pgup/pgdn as aliases but not pgdown, which the
  parser has always taken; both references name it now.
- "Case-insensitive" was flat wrong for Alt: M-x keeps its case because
  Alt is a prefixed ESC, unlike Ctrl. Both references note the exception.
- procs' ports JSON has carried addr since the field exists; both schemas
  show it.
- TTY7_WS is tab ls's default too; both environment tables say so.
- split --ratio's clamp to [0.05, 0.95] was discoverable only in code;
  both split sections document it.

* fix(cli): doctor exits 1 when the server is unreachable (#592)

doctor is the verb people run when something is not working, so an
unreachable server is *the* finding — not a row to exit 0 over while
`tty7 doctor || alert` never fires. The table and JSON still go out
(the context rows are the other half of what doctor is for), and stderr
carries the headline under -q. MockBackend grows an `unreachable` flag
so the branch is testable; no Status/Routes round-trips happen once
hello has failed.

* fix(settings): refuse a Start-in path that names no directory (#601)

The custom path was stored unchecked, and the daemon's picker then
skipped it — not a directory — so every new pane silently started in
the fallback directory and the typo read as a tty7 bug. Settings now
marks the field red and refuses to save, the proxy row's pattern
(#551), with the red line and the commit gated on one shared predicate
so they can never disagree; a hand-edited config.json holding such a
path gets a log::warn! naming it at the moment the fallback engages.

* fix(terminal): rescan search highlights when the pane's width changes (#586)

A match point is an absolute (line, column) against the width it was
scanned at, so a column change reflows the text out from under every
highlight. Output rescans them (Wakeup → refresh), but a quiet local
pane has no output coming and the drift outlasted the resize
indefinitely. set_grid_size now rescans on a column change with the
output path's discipline — selection and scroll untouched — and takes
the Context it needs to do so; a rows-only change reflows nothing and
stays cheap.

* fix(terminal): keep the grid selection when the search bar opens and closes (#584)

The selection that seeds the query is the thing being searched for, yet
opening the bar ran recompute_matches' unconditional clear — right for
its other callers, where the user *changed* the query and the old
selection names nothing — and closing cleared it again, so select →
Ctrl+F → Esc lost the selection every time. The seeded selection is now
restored after the opening scan, and close_search no longer clears; a
query the user actually changed still retires the stale selection, the
discipline refresh_matches_after_output already stated.

* fix(tabs): a zoomed pane stays zoomed across a tab switch (#599)

Zoom was a window-level value that activate() cleared unconditionally,
so looking at another tab and coming back restored the split layout —
while a zoom is a tab's temporary view state, like its focused pane.
It now rides with the Tab: activate stashes the outgoing tab's zoom and
brings the incoming tab's back. The clears that genuinely reshape the
layout (drag, split, close) still stand, and a stashed zoom whose pane
exited while the tab was away is validated away rather than restored.

* fix(tabs): track an open rename box by tree id, not index (#598)

The rename box held only an index, which drifts the moment any other
tab closes or the strip reorders — so close_tab_inner and
apply_tab_order threw the half-typed name away on any unrelated tab
event, and a reorder mid-rename still left a window where the commit
landed on whichever tab had taken the index over. The box now names its
tab by tree id end to end (start, render match, commit): only closing
the renaming tab itself ends the rename, and the name lands on the tab
the box was opened on wherever it has since moved.

* fix(i18n): move seven hard-coded user-facing strings into the language tables (#602)

Seven spots rendered English no matter which UI language was set: the
shell-integration notice that explains why a wrapper was blocked or never
engaged, the titles a pane wears once its process exits or the server
loses it, the loopback forward's failure line, the tray tooltip that
lists running agents (whose separator also wanted a CJK enumeration
comma), the cursor-shape choices in settings, the command palette's
empty-result hint, and the updater's install hint. Each is a L10nKey now
with en/zh/ja entries, so the parity guard keeps them translated from
here on.

The palette's empty state was also wrong in content, not just language:
every menu suggested connecting over SSH when nothing matched, including
menus that have no hosts in them. The hint now only appears in the
quick-connect menu; everywhere else the palette suggests a different
search instead.

Verified on Linux: the title/palette/tray suites (48 tests) and the i18n
parity guard all pass.

* fix(terminal): show remote path completion is listing, and say when it fails (#585)

Tab-completing a path on a remote workspace had two silences. The whole
network round-trip painted nothing, so a slow link read as a broken Tab
key; and a listing that failed was unwrapped into an empty candidate
list, so "the directory is empty" and "the listing never happened" ended
in the same nothing.

A pill over the pane's bottom-right corner — the style the integration
notice already uses, factored out — now says the listing is running from
the moment it starts, and a failed listing sets a notice with its error
instead of the empty vector. The failure pill stays until the next
keystroke dismisses it, and the trailing notify after an empty listing
closes the menu brings the "listing…" pill down with it.

Verified on Linux: the new gpui test covers the idle/listing/failed
states, and the neighbouring completion tests still pass.

* fix(files): quote cd Here / Insert Path for the shell the pane runs (#593)

Both file-tree actions wrapped a path with spaces in POSIX single quotes
whatever the focused pane's shell was. In cmd.exe a single quote is an
ordinary character, so `cd 'C:\Users\me\My Documents'` split at the
first space and cmd complained about 'C:\Users\me\My' — while the same
action was fine in PowerShell and bash, which is why only cmd users ever
saw it.

shell_quote_for takes the pane's shell program (the pane already knows
it — the settings page lists it) and picks double quotes for cmd.exe,
single quotes for everything else; an unknown shell keeps the POSIX
form, and a path that needs no quoting stays bare either way. Windows
paths cannot contain a double quote, so the cmd form has nothing to
escape.

* fix(cli): pane close fails for a pane the registry does not hold (#588)

`tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane
that never existed. The workspace path cannot drift this way — PaneClose
answers — but an orphan has no workspace to route through, so close
hangs it up directly, and that kill is fire-and-forget: the daemon never
says whether it knew the pane, so Ok(()) only ever meant the bytes
reached the socket. A reaper script chasing the orphans `pane ls --all`
points at would read the ghost success as cleanup done.

The direct path now reads the running-pane registry once per batch and
refuses ids it does not hold: the miss lands in `failed` with exit 1,
next to the failures kill itself can report. A pane that exits between
the listing and the kill is gone either way, which is what closing it
wanted, so that race still reports closed.

* fix(session): a launch that leaves workspaces running says so (#597)

Quitting with several windows open and starting again restored only the
most recent one; every other open window was marked detached — panes
alive, nothing on screen, the only trace a "left N detached" log line.
The workspaces were reachable from the sidebar, but nothing said they
existed, so they were easy to forget entirely.

restore_one now returns how many windows it detached, and both launch
paths (normal startup and the CLI-driven open) push an in-app
notification into the restored window naming the count and where to
reopen them. The count rides the return value rather than firing the
notification inside the store, because the store has no window to notify
in — and a launch that detaches nothing, like the reattach-the-last-
closed case, stays silent.

* fix(switcher): list the local machine's orphan panes, with a way to close them (#596)

A pane whose workspace went away — an interrupted `tty7 run`, a forgotten
workspace that kept its shells — was invisible everywhere in the GUI: not
in the sidebar, not in the switcher, not in the tray. It kept its process
and its memory, and the only way to even learn it existed was the CLI's
`tty7 pane ls --all`, which a GUI-only user never runs.

The switcher's local machine group now carries a "Background panes" block
under its workspace rows: one line per live pane the daemon's registry
holds and no workspace does — id, owner, cwd — each with a Close button.
The listing is the same PaneClient::list the CLI's reaper reads, fetched
off the UI thread when the panel opens; closing kills and then re-lists,
so a pane that survived simply stays on the list instead of pretending
to be gone. The block steps out of the way while the search field holds
a query, which narrows the panel to workspaces.

Local on purpose: a remote machine's orphans belong to its own daemon,
and routing a listing per host is what the CLI reaper is already for.
The block joins no keyboard navigation — the panes are not workspaces
and the arrows have no business landing on them.

* fix(updater): keep Inno's progress window on screen during the install (#600)

The Windows installer ran /VERYSILENT, so from the app quitting for the
update to the watcher bringing the new build up — tens of seconds, longer
under an antivirus scan — the screen held nothing at all: no window, no
progress, no tray note. "Clicked update, the app vanished" reads as a
crash, and double-clicking the icon does nothing while the files are
being replaced.

The installer now runs /SILENT instead. Nothing about the flow becomes
interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS
are untouched — but Inno's own progress window stays on screen for the
gap, which is exactly the span the user had no word about.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 18:11:17 +08:00
l0ng-aiandl0ng-ai 3c0a700907 feat(switcher): flat workspace list, create form, connect-time workspace sync (#616)
* feat(switcher): flatten the workspace list, add a create form, sync remote listings on connect

The switcher's left column is now one flat most-recently-used list across
all machines, each row carrying its machine and link state; the per-machine
tree, headers, and the Other Machines band are gone. Machine trouble
(install progress, connect errors, parked routes) moves to contextual
banners under the list, and machine verbs move into each row's menu.

Cmd+Shift+N now opens a create form instead of silently swapping the
workspace: a name prefilled with the usual generated codename, and a host
combobox (searchable dropdown) defaulting to this computer. Creating on a
machine with no live link connects first and completes when the link is up.

Connecting to a machine also mirrors its workspace listing into the local
store, so its workspaces survive a restart without a connection. Mirrored
references are marked synced: launch restore skips them, and their clock
follows the machine only until this client opens them.

* fix(switcher): drop a parked create when its machine's connect is called off

Disconnect clears the in-flight connect, so finish_connect never runs and
the PendingCreate outlived the intent behind it: the next successful
connect to that machine would have silently created a workspace nobody
was waiting for anymore.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 17:35:58 +08:00
l0ng-aiandl0ng-ai 6c26b35acc fix(control): bump the dialect to v6, and publish a tty7-server for macOS (#605)
* fix(control): bump the dialect to v6 so an out-of-date server says so

The control dialect has been renamed, extended and cut since it was
last numbered, all of it against CONTROL_VERSION 5: the machine tree
replaced WorkspaceList/Get/Put/Delete with WorkspaceTree, MachineGet
and the tab/pane verbs, GitStream arrived with its chunk and end
events, and ReplyOk::Attached and FileMeta went away.

A peer left behind by any of that still answers the hello, because the
number it answers with still matches. It is also still sitting at the
path the installer looks for, tty7-server-c5p5, so a client decides it
already has the server it needs. Then the first call reaches a variant
the peer has never heard of, the frame fails to decode, and the read
loop takes the whole link down with it. What the user sees is a remote
workspace that opens with no tabs and a git detail pane that never
fills, with nothing anywhere saying why.

Moving the number puts all three guards back: the hello is refused with
the message that names the old build, the remote binary is looked for
at c6p5 and installed rather than trusted, and a stale local daemon
gets the restart prompt it should have been getting all along.

Document the rule next to the constant while it is fresh: move it when
a variant is added or removed. The feature strings only cover what a
peer can safely ignore, and a request it cannot decode is not that.

* feat(remote): publish a tty7-server for macOS hosts

A remote workspace has been Linux-only for no reason anyone chose: the
installer derives the asset name from `uname -sm`, and the only names it
knew were the two musl builds. A Mac on the other end of an SSH profile
got "a remote tty7 workspace needs a Linux host" and stopped there.

Publish the two Apple slices alongside them and teach the installer to
ask for them. `Darwin arm64` and `Darwin x86_64` now map to
tty7-server-macos-aarch64 and tty7-server-macos-x86_64; everything past
that point already worked, because nothing under it was ever Linux-
specific — the install path is POSIX, the upload is SFTP, and the
dialect probe runs the binary before trusting it.

The machine names are matched per system rather than by architecture
alone. Linux says aarch64 on one distribution and arm64 on the next,
while a Mac only ever says arm64, so honouring Linux's spellings under
Darwin would be guessing at output no Mac produces.

Static linking is not the instrument on macOS — Apple ships no static
libSystem — so assert-macho.sh stands in for assert-static.sh with the
guarantee that actually matters: every dependency resolves under
/usr/lib or /System/Library, so nothing the destination Mac lacks can be
picked up from a build runner, and the binary carries the signature
arm64 refuses to run without.

Not signed or notarized beyond that, deliberately. The binary is never
downloaded by the Mac that runs it: the client fetches it, verifies it
against checksums.txt and writes it over SFTP, which sets no quarantine
attribute, so Gatekeeper is not in the path.

ASSET_X86_64 and ASSET_AARCH64 become ASSET_LINUX_*, which is what they
always meant and could not keep meaning next to a macOS pair.

* fix(ci): sign the x86_64 macOS server, and stop the guard flaking on it

Two faults the first green run hid from each other.

The linker ad-hoc signs the arm64 slice because Apple Silicon will not
execute anything unsigned, and leaves x86_64 bare. That is fine on an
Intel Mac, but the x86_64 server is also what an Apple Silicon box gets
when it asks through a Rosetta shell, and handing that machine an
unsigned binary is a guess about Rosetta nobody needs to make. Sign both
slices ad-hoc in the workflow — no identity, no secrets, nothing to do
with the notarized signing the GUI bundles get.

The guard that caught it was itself unreliable: `codesign -dv | grep -q`
under `pipefail` reports failure whenever grep wins the race, because -q
exits on the first match and the writer takes SIGPIPE. Small output means
the writer usually finishes first, which is why the arm64 job passed and
x86_64 failed on the same signed-or-not question. Capture into a variable
and match afterwards, the way the release workflow already does it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:47:27 +08:00
l0ng-aiandl0ng-ai 49901d7f8a fix(cli): let --enter press the key it is shorthand for (#581) (#606)
`--enter` is documented as sugar for `--key enter`, but the send dispatch
counted only `args.keys`, so `tty7 send %42 --enter` answered "needs TEXT
... or a --key to press" and pressed nothing. The key list is now built
before the dispatch and the dispatch counts it, so a marked address with
`--enter` and nothing else runs what the pane already has typed, and a
bare `send --enter` presses Enter where the caller sits.

An unmarked id is deliberately left out of that promotion. #567 made the
address slot take bare ids, and `send 83 --key C-c` addressing pane 83 is
fine because `--key` says "press this" and nothing else. `--enter` does
not: `send 2 --enter` reads at least as much like typing 2 into your own
pane and running it, and turning it into a keystroke at pane 2 would be
the silent retarget #567 spent its diff closing. It stays a loud error,
now naming both spellings (`send %83 --enter`, `send %PANE 83 --enter`)
rather than only the typing one.

The reference, the bundled skill reference, `send --help` and the
`--enter` help all said the old thing in slightly different words; they
now say the same thing as each other and as the code.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:43:17 +08:00
Hongwei Qinandl0ng-ai 664b766698 fix(settings): split Shell Arguments like a shell, quote them on the way back (#551) (#573)
* fix(settings): split Shell Arguments like a shell, quote them on the way back (#551)

The field split on raw whitespace, so `-c "echo hi"` became four argv
fragments with the quotes still attached, and it silently rewrote config
too: `build_shell_inputs` refilled the field with `args.join(" ")`, which
cannot spell an argument containing a space, so a legal
`"args": ["-c", "echo hi"]` in config.json re-committed as three argv on
the next blur without the user typing anything.

Parse with shell-words rules instead and quote each argument on the
refill, so field text and the argv array round-trip losslessly. An
unbalanced quote cannot become argv at all, so commit refuses it and the
row explains why under the input — the proxy field's pattern. The field
description in en/zh/ja now says quoting works.

Program gets the milder half of the same treatment: a bare command that
detection (a PATH probe) never saw is almost always a typo like `pwsh7`
that today only surfaces when a pane fails to open, so the row warns
under the field. It never refuses — the field stays free-text so a shell
detection missed remains reachable — and anything spelled as a path is
taken at its word. The comparison reuses core's `same_shell_program`, so
"known" here means exactly what the new-tab menu dedup means.

shell-words was already in the tree via portable-pty, so the direct pin
adds no new code.

* fix(settings): split Shell Arguments as argv, not as POSIX source

Review pass over the #551 fix. Splitting with `shell-words` bought the
quoting contract at the price of two silent rewrites of its own, both the
same shape as the bug being fixed: a backslash outside quotes is a POSIX
escape, so `--dir C:\Users\me` committed as `C:Usersme` on the platform
where that is how a path is spelled, and `#` opens a comment, so
`--tag #1 --verbose` committed as one argument. The refill was noisier
than claimed too — `shell_words::join` quotes on `=`, `~`, `*`, `?` and
`[`, so an existing `--color=auto` came back as `'--color=auto'`.

Nothing here is a shell: the field is a text spelling of an argv array
that goes to `CommandBuilder` directly. So split and join are now a local
pair sized to exactly that job — quotes group, `\"` and `\\` inside double
quotes escape, everything else is a character — and the direct
`shell-words` pin goes away again. They are exact inverses, which is what
`config.json` needs, and the test walks the round trip over the cases a
space-join cannot spell plus the two above.

Drops the Program nudge. `shells::inventory()` inserts the *configured*
shell into the inventory it returns, so `pwsh7` is in `self.shells` from
the next refresh onward: the warning could only ever flash between the
commit and the refresh landing, and never appeared at all on a later visit
to Settings. Its test passed because it hand-built an inventory that
version of the value could not be in. Making it true needs core to say
which rows were detected rather than configured, and that is a serialized
protocol struct — too much for a nudge the issue itself called the milder
half.

Refusing the arguments no longer drops the Program typed or picked beside
them: the stored argv stays as it was, which is what "this value was not
saved" already told the user, and the shell picker works again while the
arguments field is mid-edit.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:32:18 +08:00
Hongwei Qinandl0ng-ai cb473daf27 fix(cli): refuse a broken send address instead of typing it into your pane (#538) (#567)
* fix(cli): refuse a broken send address instead of typing it into your pane (#538)

A lone positional that starts with `%` but fails parse_pane (`%3x`) used
to fall through to the text branch: the typo was typed into the caller's
own pane and any --key followed it there, so one wrong character
redirected an interrupt to whatever the caller was looking at. The guard
now propagates the parse error when the `%` is followed by a digit —
"clearly tried to write an address" — and leaves `%`-led text whose
second character is not a digit (`%s/foo/bar/`, `%!sort`) on the text
path it always was, per the review's narrowing.

The explicit address slot also accepts bare ids now: `pane ls --json`
prints `83`, not `%83`, and refusing the bare form made the workaround
for the typo hole (`"%${TTY7_PANE#%}"`) uglier than the hole. This
matches what pane_from_env already accepted and closes the missing-`%`
variant of the same mistake.

Tests cover the branch with a `Context { pane: Some("5") }` — every
existing send test used `Context::default()`, where the fallback errors
OUTSIDE_SHELL before the guard is reachable, which is why the hole had
no test.

Also correct the `ws rm` docs (#539): the reference claimed its panes
become orphans found via `pane ls --all`, but the code has hung them up
since #319; only a hang-up failure (reported by pane id) leaves
orphans. The site reference, the skill reference, and `ws rm --help`
now say so.

* fix(cli): keep the send guard to what actually looks like an address

The narrowing was described more widely than it works: a digit-led token
that fails to parse (`3x`) still types, only `%` then a digit refuses, so
the reference and the skill both promised an error that never comes. Say
what the code does and point at the two-argument form as the way to type
an address-shaped string anyway.

Now that the `%` is optional, `parse_pane` also has to be stricter than
`u64::from_str`, which accepts a leading `+`: a bare `+5` meant as text
would otherwise address pane 5. An address is digits and nothing else,
and `pane_from_env` delegates rather than repeating the read.

The broken-address arm parsed twice and ended in an `unreachable!` that
a future edit could walk into; one match on the parse result carries the
error out directly. A lone bare id is the one behaviour this takes away,
so it says how to type the number instead, and a test pins that it never
quietly presses a key at the pane the id names.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:24:58 +08:00
l0ng-aiandl0ng-ai d343fd8a13 feat(links): open file links in tty7, resolved on the pane's own host (#568)
* feat(links): open file links in tty7, resolved on the pane's own host

A clicked file path now opens in the built-in editor at the line and
column the link named, and the Files panel reveals it; a directory link
opens the panel on that directory. Settings -> Terminal -> Links -> Open
files with picks between the built-in editor, the OS file association
and a command, migrating anyone who had already set link_file_command.

Detection is split into a filesystem-free candidate parser and a probe
callback, so a pane whose paths live on another machine resolves them
there instead of against the local filesystem -- an absolute path used
to open this machine's copy silently. A pane running ssh typed into a
local shell can answer for neither side and no longer offers file links
at all.

Relative paths are measured from the directory the work is happening in
(the agent's, not the shell's kernel cwd) and then from the repository
around it, and a path that matches nothing under either now says so
instead of the click doing nothing.

* fix(links): keep a remote path off the local openers, and off a dead end

Review follow-ups on the file-link work.

- A file resolved on another machine now opens in the built-in editor
  whatever `link_file_open` says. Under `system` or `command` the path was
  handed to a local `open` / `code --goto`, which threw away the resolution
  just done on the pane's host and silently showed this machine's copy — the
  same bug this branch set out to fix, left live for two of the three modes.
  A directory outside every tree root says so instead of opening a local file
  manager on a path that belongs to the far side.

- `flush_link_probes` takes the host before it takes the wanted paths.
  `take_wanted` moves them into the in-flight set on the promise that a call
  is carrying them; a host that had gone away broke that promise for good and
  left those paths permanently unanswered — no underline, and a click that
  says nothing.

- `~` no longer borrows this machine's `$HOME` for a pane whose paths are
  elsewhere. A cwd outside `/home` and `/Users` used to fall back to it, so
  `~/.zshrc` on a Linux box became `/Users/me/.zshrc` and was asked about —
  and possibly answered — over there.

- An unresolved absolute or `~`-rooted path no longer claims it was looked
  for under the pane's directory. It never was: roots are only for relative
  paths.

- A pending tree reveal counts down whether or not its row was found. A row
  that never reported bounds kept the request alive for good, re-issuing a
  scroll on every render and holding the column against a hand scroll.

- The repo root comes from `GitStatusCache` when the git-status probe has
  already asked about that directory, rather than a second round trip.

Tests: the migration `link_file_open` exists for (an old config with a
command lands on Command, one without on the editor), a probe with no host
staying wanted, and `~` refusing this machine's home for another one.

* test(links): only claim a leading slash is absolute where it is

`is_rooted` asks `Path::is_absolute`, the same question `FileCandidate::paths`
asks before it decides the roots do not apply — and on Windows `/etc/hosts`
answers no to both. The predicate is consistent; the assertion was not, so it
now lives in a unix-gated test of its own next to the untouched one.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 22:48:16 +08:00
l0ng-ai 473c94ecba docs(skill): show how to update an installed tty7 skill
`skills add` does not refresh a skill that is already installed, so the
one install line left existing users with no documented way forward.
Add the `skills update tty7` counterpart to both READMEs and give the
agent-skill page a short Updating section covering update and remove.
2026-08-12 17:09:55 +08:00
webdev 4da3868797 feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443
2026-08-12 10:52:35 +08:00
l0ng-aiandl0ng-ai b7196ae49a Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show

The panel's Session table rendered every fact the same inert way, and its
two actions sat in a strip of their own under the whole list — unlabelled,
four rows below the path they acted on. Rows now carry their own shape:
`changes` is the sidebar's green-and-red `+N −M` and opens the same diff
overlay under the same setting, an agent wears the same status dot its tab
does, and what a row can do appears at the end of it on hover, in the strip
Source Control rows already use. A port row hands over its address instead
of leaving it to be retyped, and the lit panel tile closes the panel the way
every other activity bar does.

* fix(right-panel): answer for the row the pointer is actually on

Review of #531 found the new rows promising more than they could keep.

Port rows keyed their element id on the port alone, but a port is only
unique with its pid — a pre-forking server puts one row per worker on
screen, and gpui handed them a single interactive state, so a click on
one lit the tooltip and the pressed fill on all of them.

The `changes` counts were read off `Tab::git_status`, which resolves a
split tab to its *first* leaf, while the click target came from
`detail_pane`, which resolves it to the *last focused* one. Inert text
could disagree harmlessly; a button could not, and clicking `+2 −0`
opened another pane's repository. Both now come from the pane the rest
of the rows describe.

A port is only `localhost` if localhost reaches it. `lsof`'s bind
address was parsed and dropped, so a server on `172.17.0.1:8080` was
offered as `localhost:8080` — a refused connection, or somebody else's
service. `PortEntry` carries the address (`serde(default)`, so an older
daemon still answers), and the wildcard and loopback binds keep the
`localhost` spelling anyone would type.

The browser tile hung off `remote_context()` — where the *shell* is —
though the ports come from the pane's own process tree either way. It
hid the tile on the one pane where it works, a `ssh -L` forward listening
on this machine. It is about the host now.

The action strip is opaque and pinned to the row's right edge, so on the
working-directory row it covered the leaf that the head-first elision
exists to preserve. The value holds that width back for good rather than
on hover: taking it on hover would re-elide the path under the pointer,
which is the pixel-shifting the strip is absolutely positioned to avoid.

Also: the counts were `flex_1`, so the whole rest of the line was the
button and empty space underlined numbers it was nowhere near; the agent
pip was pinned in pixels inside rem-sized text and slid off its line at
any interface scale but 100%, and drew Waiting as a thin ring where the
tab strip punches a hole in a filled dot — one rule, two dialects; the
panel-toggle chrome tile, which on macOS lives inside the panel it
closes, still dropped focus into the destroyed element and left ⌘J
dead; and `scm/detail.rs` kept a third copy of `ROW_INSET`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 09:58:47 +08:00
Hongwei Qin c2950fc434 feat(ui): forget orphaned remote workspaces when a profile is deleted (#508)
Deleting an SSH profile used to leave every remote workspace entry that had
connected through it behind, labelled with a bare internal id and retrying a
route that could never work again.

`RemoteRef` now carries a `RouteSnapshot` of the profile it was made from —
name, user, host, port — written at creation and refreshed on every reopen,
`serde(default)` so older session files load. The snapshot serves labels only:
`PartialEq`/`Hash` ignore it, or a refresh would split one entry into two.

Deleting a profile cascade-forgets the entries routing through it. Forgets,
not deletes: `WorkspaceRemove` is never sent, so the sessions on the remote
machine keep running and connecting again under a new profile brings them
back from the machine's own workspace list. An entry holding a live or
in-flight link is left alone, as is one whose window is still on screen — a
window whose workspace the store has forgotten reads as local, and its next
tab would open a local shell on what the user still sees as a remote box.
Whatever survives parks instead: no retries, no error, and an inline action
to drop it deliberately. A live or preempted link outranks a lost route.

Labels fall back from the live profile to the snapshot to a placeholder, so
no branch renders a bare UUID. Resolving the live name reads memory rather
than reparsing `~/.ssh/config`, because that path runs on every frame of a
window with a remote workspace open.

Closes #485.
2026-08-11 22:10:56 +08:00
l0ng-ai 657bb4e0b7 chore(assets): crop the hero screenshot tighter around the window 2026-08-11 21:54:19 +08:00
l0ng-aiandl0ng-ai 27880c0f14 feat(cli): wait on commands, press keys, and reap orphan panes (#505)
* feat(cli): wait on commands, press keys, and reap orphan panes

`tty7 wait` was the orchestration primitive for agents only. A pane with
nothing reporting agent status read as `idle`, so `--until idle` returned
success instantly about a shell that was midway through a build, and there
was no state that meant "the command finished" at all.

Panes with no agent now report `no-agent`, and `free` ends the wait when the
foreground command has exited — the process-tree question `procs` could
already answer but nothing could block on. `send --key` covers the keystrokes
text cannot express, which is what a worker stopped at `waiting` is usually
asking for. `pane close` takes several panes and `--orphans` clears what an
interrupted `run` leaves behind. `doctor` finally performs the hooks check
its own help has advertised.

The skill shipped in this repo predated `wait` entirely and taught a
hand-rolled `procs` polling loop with no notion of delegation; it now covers
the loop, and its agent statuses, `ws rm` orphan claim and not-implemented
list are corrected against the code.

* fix(cli): close the gaps review found in wait, --key and pane close

Five things the first pass got wrong, in the order they bite.

`--until free --changed` waited on a command it had already missed: the
"something ran" edge is only set by a poll that catches the pane busy, and a
command that starts and finishes inside one 500ms interval never is. That is
indistinguishable from a command that never ran, so the timeout now names both
doors instead of letting a finished build read as a hang.

`free` also outranked the agent ladder, which is backwards. A pane whose depth-0
process *is* the agent — the tree cannot tell that apart from a shell at its
prompt — reads free for its whole turn, so a `waiting` the caller explicitly
asked for could be overwritten by a process-tree fact and then withheld by the
`--changed` rule that comes with it. `free` is now consulted only when none of
the requested agent states answered, which is both cheaper and what the docs
already claimed. An empty process tree is "we could not see in" rather than
"free" for the same reason `no-agent` exists.

`--key M-X` sent `ESC x`: the whole spelling was folded to lowercase, which is
free for Ctrl (the C0 rule clears the case anyway) and wrong for Alt, where the
character rides through as itself.

`send --help` listed the key vocabulary by hand next to the table it is a list
of; it had already drifted by one alias. It is generated now.

And a `pane close` batch that could not close everything raised an error, which
left `--json` holding prose exactly when a cleanup script needs to know which
panes are still its problem. It exits 1 with `{"closed":[…],"failed":[…]}`, with
the complaint still on stderr so `-q` reports it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 20:38:27 +08:00