Commit Graph
111 Commits
Author SHA1 Message Date
l0ng-ai ac90f6c212 fix(doctor): say when the server is running panes nothing holds
An interrupted `tty7 run` strands a pane. So does a window reconciling its
layout while something else edits the tree — measured at 17 from 160
operations, every one a live `zsh` holding a pty and its descriptors, and
`tty7 pane close --orphans` ended all 17.

None of that was news to tty7: `pane ls --all` names them, the switcher lists
them, and the reaper works. What was missing is that nothing volunteered they
existed. `doctor` printed `status  pid …, up 6s, 2 panes` and counted the
stray among them, so the one verb somebody runs when something feels wrong had
the number and did not say what it meant.

A row and a `server.orphans` count. Deliberately not an exit code: one stray
after an interrupted `run` is ordinary and the reference documents it as such,
so `tty7 doctor || alert` firing on it would cry wolf. What was missing was
the sentence, not an alarm.

Counted inside the arm that already has the machine tree in hand for the
dangling-context row, so it costs no extra round trip, and under `server`
because it takes a server to have panes at all.

The first draft put the count at the top level of the JSON, and
`the_doctor_json_sections_are_the_ones_the_reference_names` — added earlier
today for exactly this — caught it as an undocumented section before it went
anywhere.
2026-08-23 08:13:50 +08:00
l0ng-ai fa5b901d8d fix(doctor): check the shell every new tab is going to launch
$ cat config.json
    { "shell": { "program": "/nonexistent/shell" } }
    $ tty7 new
    tty7: spawning a shell: daemon refused Spawn:
          no such program on this machine: /nonexistent/shell
    $ tty7 doctor | grep config
    config           ok

Nothing can open a tab, and the verb that checks the install says the config
is fine — which it is, in the only sense that row means: the file parses. The
same table already reports an unusable `custom_shells` entry, and that costs a
menu row. This costs every tab, every `tty7 new`, and the GUI's new-tab button.

The check is the daemon's own `shell_program_problem`, moved from
`daemon::pane` to `core::shells` beside `unusable_custom_shells` so both
callers share one definition and the row cannot drift from the refusal it is
predicting — it prints the same sentence the spawn will. Missing, a directory,
and not executable are told apart, because they are three different fixes.

A program given as a bare name is still not reported: the OS resolves it
through PATH and guessing at that would be worse than silence — the moved
comment says so and the moved tests pin it. Skipped under `-m` for the same
reason the hooks row is: a path checked here would answer about the wrong
machine.

Verified against a live daemon across all five cases — missing, directory,
non-executable, bare name, and no `shell` set — with exit 1 for the three that
break and 0 for the two that do not.
2026-08-23 06:11:12 +08:00
l0ng-ai 4805cb1765 fix(doctor): notice a config directory that cannot be written to
Made the config directory read-only and asked doctor about it:

    CHECK            RESULT
    TTY7_CONFIG_DIR  set (/tmp/…)
    config           none yet — the defaults are the config
    server           ok (build 26.8.3)
    …
    rc=0

Meanwhile, on the same directory:

    $ tty7 new
    tty7: could not write the machine tree at /tmp/…/machine.json:
          Permission denied (os error 13)

Every `new`, every `tab new`, and every settings save fails, and the verb
whose entire job is "check this install" called it healthy. The config row is
about *reading* — it is answering "does the file parse", and there is no file
— so nothing in the table was wrong, and nothing in it was the answer either.
`tty7 doctor || alert` is exactly the thing that should have fired.

Now: a row naming the directory and what stops working, a headline on stderr
so `-q` still says it, `config.dir_writable` in the JSON, and exit 1 alongside
the unparseable-config case it sits next to.

The check writes a probe file and removes it, rather than reading the mode
bits. A read-only mount, an ACL, an immutable flag or another user's directory
all leave `0700` on something that refuses every write, and what is being
diagnosed is whether the write succeeds. A directory that cannot be reached at
all counts as unwritable — an install nobody can open is not a healthy one.

Verified against a live daemon in all three states: healthy exits 0, read-only
exits 1 with the row and the headline, and fixing the permissions goes back to
0. The unit test covers the probe both ways and that it cleans up after
itself; replacing it with an `exists()` check fails it.
2026-08-23 05:47:16 +08:00
l0ng-ai a67234266b docs(cli): name doctor's config section, and pin the shape
`tty7 doctor --json` has emitted a fourth top-level section, `config`, since
it started answering for a config file that parses but carries keys tty7 does
not read. The reference went on describing three. Checked the documented shape
of every `--json` verb against a live daemon; this was the one that disagreed.

It matters more here than elsewhere. `doctor` is one of the three verbs whose
JSON is printed even when the verb fails — an unreachable server is the
finding, and `tty7 doctor || alert` needs the rows as well as the code — so
something is always parsing this.

The page now names `config`, says what `ok` and `state` mean, and says which
of the table's rows are prose rather than fields, so the next reader is not
left looking for `config keys` in the JSON.

The guard compares doctor's top-level sections against the ones the reference
line names, both directions. Top level only: the fields inside vary with what
a server could be asked — `context` gains `workspace_gone` and `pane_gone`
only when one answered — and the page explains that in prose it would be wrong
to pin to a fixed list. Verified by dropping the section from the page, and by
dropping it from doctor.
2026-08-23 05:06:39 +08:00
l0ng-ai acd7d69815 fix(ssh-config): report a forward that cannot be imported, rather than dropping it
Differential-tested the ssh_config parser against `ssh -G`: `Host=name`,
keyword case, tabs, multiple patterns on a line, negation, first-value-wins
across blocks, `%h`, quoted ProxyCommand, a `#` mid-value not being a
comment, glob includes, the `Host *` fallback. All agreed. Host patterns are
case-sensitive in OpenSSH and case-sensitive here, which also agreed.

Forwards did not. OpenSSH takes a Unix socket on either end and a service
name wherever a port goes — `ssh -G` accepts `LocalForward /tmp/my.sock
localhost:80`, `LocalForward 8080 /tmp/remote.sock`, and resolves
`localhost:http` to port 80. A `ForwardRule` is a host and a `u16`, so
`parse_forward_rule` returns `None` for every one of them and the rule never
reaches the profile.

Not being able to hold those is a limitation and is now written down. What
made it a bug is that the import report answered by keyword alone:
`localforward` was on the supported list, so the report claimed a forward it
had thrown away. Someone whose tunnel never came up had nothing to read —
the report said it was there.

The supported-list check now takes the value and, for the three forwarding
keywords, asks whether the rule actually parses. It stays one list, so the
resolver and the report cannot drift apart. `docs/remote/ssh.mdx` lists the
omission beside `Match` and GSSAPI, and the guard reads that line as well as
checking that the forwards which do fit still come across untouched —
otherwise honesty could have been bought by reporting everything. Checked
against an injected regression.
2026-08-23 02:59:18 +08:00
l0ng-ai 3064ce8a68 fix(history): stop per-pane history from silently disabling history search
`<config>/history` was two different things at once. The window keeps the
input bar's command store there as a *file* (`terminal::history`), and the
daemon put each pane's `HISTFILE` in a *directory* of the same name. A path
is one or the other, and the daemon created its directory the moment
`per_pane_history` was switched on, so the daemon won.

After that the window's `append` opened a directory, got an error, and — the
call site being `if let Ok(mut f) = open(..)` — dropped the command line
without a word. Up and Ctrl-R went on offering whatever had been recorded
before the setting was turned on and never grew again. Nothing in the UI or
the log said why, and the two halves are in different crates, so neither side
had any reason to notice the other's name.

Per-pane files move to `<config>/pane-history/`, which leaves the old name to
the file that should have had it. An install that already tripped over this
is carried across on the next start: the directory sitting at the old name is
exactly what belongs at the new one, so it is renamed rather than abandoned —
but only when it *is* a directory, since a plain file there is the window's
store and moving that would take the very thing this repairs, and only when
the new name is free, so a second call cannot bury a directory in use.

Verified end to end against a daemon: with the setting on it now creates
`pane-history/` 0700 with the pane files in it, and an append to
`<config>/history` succeeds where it previously failed. Seeding an
old-layout install with `history/pane-9` and starting the daemon leaves the
line intact at the new path.

Guards: one reads the window's source for the names it passes to
`config_path` and fails if the daemon's directory is among them, so moving
either side onto the other trips it; one covers the three ways the move can
lose data (directory moves, file stays, occupied target is not overwritten).
Both were checked against injected regressions. The privacy page and the
integration test that read the path follow it.
2026-08-23 02:46:54 +08:00
l0ng-ai 0d82891cd1 docs(privacy): say where per-pane history is kept
The page said shell history is "your shell's own file, exactly as before
— unless you turned on per-pane history, which merges back into it". The
merge is real, but it is not the whole account: with per-pane history on,
tty7 points each pane's `HISTFILE` at `<config>/history/pane-<n>`, and
what accumulates there is the command lines someone typed.

That is the most sensitive thing tty7 causes to be written anywhere, and
the page named neither the location nor the mode. It enumerates
`<config>/scrollback/*.bin` down to its 0600 and its retention rules;
this belongs on the same footing.

Verified rather than read off the source: with the setting on and the
server restarted to pick it up, a fresh pane reports

    HISTFILE=<config>/history/pane-1

and the directory is created `0700`. The files inside are the shell's own
writing, under the user's umask, which the page now says.

The guard asserts the path appears, not the prose around it — the path is
the part a reader needs in order to go and look.
2026-08-23 02:34:48 +08:00
l0ng-ai 56b4769d9c docs(privacy): update.log is a file tty7 writes, so list it
The privacy page accounts for every file tty7 leaves in the config
directory, and `crash.rs` has a test holding it to that. The test named
two files. There are three: the updater appends `<config>/update.log`
while it installs a release — what it verified, what it replaced, and why
it stopped if it did.

It is written whenever an update runs, not only under `TTY7_LOG`. That
makes it exactly what the test's own comment describes as belonging on
the page: "a file written without being asked for". It is also the only
account of a swap that happens after the window is gone, which is why it
exists.

The guard now names all three, and was checked against the page as it
was.

Also corrects a filename I got wrong in the previous commit: the doc
comment on `install_crash_log` called this file `tty7-updater.log`. There
is no such file — I invented the name while describing what a silent
panic costs, and it went in unchecked. It is `update.log`, and finding
that is what turned up the missing page entry.
2026-08-23 02:27:09 +08:00
l0ng-ai 36404a6cbb fix(keymap): Restart Server is bindable, like every other menu item
Forty-five items in the app menu dispatch an action. Forty-four of them
can be given a key; `RestartDaemon` could not, because it was missing
from `default_bindings` and `make_binding` — so config.json dropped the
name silently and the Keybindings page never listed it.

Nothing else was missing. The gpui action is declared, the handler is
wired, the app menu dispatches it and the palette runs it. Only the two
table entries that make a name bindable were absent.

This corrects a claim I made when I moved it out of the palette's chord
lookup: I said then that making it bindable "means a gpui action and a
handler, which is a feature rather than a fix". That was wrong — both
already existed, and the app menu had been dispatching the action the
whole time. Finding it took comparing the menu against the keymap rather
than reading either alone.

No default chord, like the sixty-odd others that ship unbound. The
palette gets its chord lookup back, so once a key is on it the row shows
it — which is the thing that lookup was doing wrong before and is now
simply right.

The shortcuts-page guard added earlier this session caught the last step
without being asked: it failed on `RestartDaemon` the moment the action
became bindable, which is what it is for.
2026-08-23 01:19:51 +08:00
l0ng-ai eb211d7fc1 docs(shortcuts): the document dock's actions are bindable, so name them
The shortcuts page has two halves: a table of default chords, and a list
of the rest — "more actions you can bind". An action with no chord of its
own appears only in that second list, so leaving it out makes the action
unfindable: no key to be discovered by, and no row to be read on.

The document dock (#625) shipped with four such actions —
`ToggleDocumentFill`, `DocumentWidthThird`, `DocumentWidthHalf`,
`DocumentWidthTwoThirds` — all bindable, all chordless, and none of them
on the page. The CHANGELOG names them as palette commands; the page a
reader goes to for "what can I bind" did not.

The neighbouring guard already holds the other direction: the page names
no action that has been renamed away. This one asks whether the page is
*complete*, which is the half a rename cannot break but a new feature
can.

Two spellings count as named, because the page uses both. The rebinding
lists write identifiers, and compactly — `ResizePaneLeft/Right/Up/Down`,
`SelectWorkspace1`…`SelectWorkspace9` — so a trailing direction or digit
is part of a family rather than an entry of its own, the same convention
the neighbouring test already keeps. The chord table writes labels, which
is how `CopyText` is covered: `per_platform("", "ctrl-shift-c")` leaves
it chordless on macOS while it is Ctrl+Shift+C elsewhere, so it earns a
table row and is written "Copy" there. The first draft of the guard
missed that and called it undocumented.

Checked against the page as it was: the guard names all four.
2026-08-23 01:04:54 +08:00
l0ng-ai 020769b3bb docs(cli): server restart keeps sessions, and --hard is the one that does not
`Restart` became `Restart { hard: bool }` and the reference page did not
follow. It still read

    | `server restart` | Stop, then start — same consequence |

which had been true before the flag split the two apart. So the page told
a reader that restarting the server kills every pane on the machine — it
does not — and said nothing whatever about the option that does.

Both halves measured against a running server with a `sleep 600` in a
pane, not taken from the help text:

    server restart          "restarted in place; sessions kept running"
                            shell 74647 still alive, pane still LIVE
    server restart --hard   "stopped and started; sessions ended"
                            shell 74647 gone, pane LIVE=no

Wrong in the cautious direction, which is the quiet kind: someone who
wants their server on a new build reads that line, believes it will cost
them every shell, and does not run it — while the destructive spelling
they were never told about sits one flag away.

The guard walks clap's own command tree and holds every long flag to
being named somewhere on the page. Names only: whether the prose around a
flag is right is not something a test can hold, but a flag missing from
the page entirely is, and that is the state that shipped. Global flags
are exempt — they repeat on all forty-odd verbs and the page documents
them once, in a table of their own.

Checked against the page as it was: the guard reports `restart: --hard`.
2026-08-23 00:58:36 +08:00
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