Commit Graph
54 Commits
Author SHA1 Message Date
l0ng-ai ed05c57bfd fix(cli): doctor said nothing about a config it was not using
`tty7 doctor` is described as checking "socket, dialect, config,
versions, agent hooks, links, context". With a `config.json` that does
not parse it printed every row green: server ok, dialect ok, status ok.
Meanwhile every setting in that file was being ignored, a copy had been
kept beside it, and saving was suppressed.

The rows that mention config report `TTY7_CONFIG_DIR` — where the file
should be, which is a different question from whether it is being read.
There is now a row for the answer, from the same `LoadOutcome` the
loader already returns: ok, none yet, not valid JSON, unreadable.

It also exits 1 for the two broken states, for the reason written above
the server check: doctor is the verb people run when something is not
working, and `tty7 doctor || alert` has to fire. A config nothing reads
is that, as much as an unreachable server is.

All four states run against a live server: ok, none yet, NOT VALID JSON
(rc 1), and back to ok after repairing the file with no restart.
2026-08-16 09:58:56 +08:00
l0ng-ai 20fea9826f docs(cli): the idiom I wrote for wait was missing --changed
In 335d982 I gave `tty7 wait %3 --until free` as the form to use for a
plain command. `--changed` is what makes that safe after a `send`, and I
left it out.

The states are levels, not edges. A pane that has not yet started what
you just sent it is still `free`, so a wait that gets there first is
answered by the state the pane was already in — the flag's own comment
says exactly this, and it is why `--changed` exists. The example now
sends and waits the way a delegation loop actually runs.

Being straight about the evidence: I did not reproduce the race. Sending
a six-second command and waiting without `--changed` blocked the full
six seconds, because the shell had gone busy before the second process
started. The window is small and the failure is intermittent, which is
the worst kind to leave in an example — not a reason to call it fine.

`--changed` itself is correct, and now checked: `--until free` on an
idle pane returns at once, while `send` then `--until free --changed`
holds for the five seconds the command takes and the output is there
afterwards.
2026-08-16 09:18:31 +08:00
l0ng-ai a0ecd9fd2b docs(cli): server restart ends sessions, and its sibling already said so
`stop` is described as "Stop the server; sessions end". `restart` was
"Stop, then start" — the same destruction, undisclosed, on the verb
people reach for casually when something seems stuck.

Measured rather than assumed: two panes before, zero after, workspaces
still there. So the layout survives, because the tree is on disk, and
what those shells were running does not.

The long form also separates this from the thing it will be confused
with. The server can hand panes to a new build of itself with their pids
and ptys kept — that is a real wire request (`ClientMsg::Handoff`, into
`hand_over` and `handoff::take_over`), and it is how an update keeps
sessions. `restart` is not that path, and someone who has read about one
should not assume the other.
2026-08-16 09:14:37 +08:00
l0ng-ai 172e8d166f docs(cli): a wait with the wrong --until never returns
`tty7 wait %N` on an idle shell blocks for as long as you let it. The
default states are `waiting`, `done` and `exit`; a plain shell with no
agent is `no-agent` and reaches none of them, so the wait has nothing to
wake for. Verified with a bounded run: still blocked at twelve seconds
against a freshly created pane.

Blocking is the contract — the verb is `wait` — so what was missing is
that choosing the wrong state is a hang rather than an error, and that
`--timeout` has no default to fall back on. Both are now said where
they are read: the trap in the command's help, the absence of a default
on the flag itself.

This is the same shape as the stdin note in the last commit. An agent
orchestrating panes has two ways to stop forever with no diagnostic, and
in both the software is doing exactly what it was asked to.
2026-08-16 09:10:33 +08:00
l0ng-ai 9833c772b0 fix(cli): call it a program, and say that stdin does not reach it
`tty7 run -- /no/such/binary` answered "no such shell on this machine".
The check in front of a spawn serves both the configured shell and a
command handed to `run`, and its three sentences all said "shell" —
so someone who typed `tty7 run -- ./build.sh` was told tty7 had
misunderstood what they asked for. They say "program" now, which is
true of both callers; the CLI already prefixes "spawning `…`" with the
context.

The other half is worse and is documented rather than changed:
`echo hi | tty7 run -- cat` never returns. The command reads the pane's
terminal, which nothing is typing into, so it waits for input that
cannot arrive — and there is no error, because an idle terminal is not
a failure. Output streams back; input does not go the other way.

Measured, after a first attempt sat for ten minutes: a bounded re-run
with `head -1` was still running at ten seconds with the pipe unread.
For a CLI whose stated audience is agents, `cmd | tty7 run -- …` is a
natural thing to try and an unbounded wait is the worst way to answer
it. Forwarding stdin would be a new capability; saying so is not, and
`sh -c 'cat < input.txt'` gets the input there today.
2026-08-16 09:06:57 +08:00
l0ng-ai 2e2cdfa242 docs(cli): say that capture answers with the alternate screen
A pane running an editor or a pager answers `capture` with that
program's screen, and the scrollback behind it is not in the reply.
Measured: with a pane on the alternate screen the capture holds the
alt-screen marker and not the main-screen one; after leaving it, the
main scrollback is back and the alt content is gone, with only the
echoed command left behind — which was typed on the main screen and
belongs there.

That is right, and it is the sort of right that reads as a bug from the
outside. An agent that captures a pane mid-`less` gets a screenful and
no history, and the obvious conclusion — output was lost — is wrong. So
the help says which of the two you are holding, and that an
empty-looking capture is usually a TUI in front of the scrollback.

Also noted there: long output is trimmed from the oldest end, so a
capture after a big build gives the end of it. `seq 1 200000` through a
pane comes back as the last ~70 KB, ending at the prompt.

Checked while here, and correct: `send` carries an 8000-character
payload with nothing truncated.
2026-08-16 08:51:18 +08:00
l0ng-ai dc870d6840 docs(cli): say that --all trades the TAB column for OWNER
`pane ls` reports PANE/WS/TAB/CWD/LIVE and `pane ls --all` reports
PANE/WS/OWNER/CWD/LIVE. The swap is right — a pane no workspace holds is
in no tab, so a TAB column would be empty for exactly the panes `--all`
exists to show — but nothing said so, and it is the sort of difference a
reader assumes they misread.

It also costs something specific: the @ numbers. A "no tab @7" error
sends the reader to `tty7 pane ls`, and reaching for `--all` there, as
one does when a thing seems to be missing, takes the column away.

Verified while here, and all correct: `--key C-c` interrupts a running
command (the `sleep` is gone and `procs` shows the shell back in the
foreground); `send` delivers quotes, backslashes, `$`, backticks, braces
and pipes verbatim; `-q` silences all twelve listing and creating verbs,
still prints errors, still exits 1, and wins over `--json` on success.
2026-08-16 08:07:48 +08:00
l0ng-ai d601a86f11 docs(cli): the environment comes from the server too
Same shape as the working directory, and the same surprise:

    RALPH_MARKER=hello tty7 run -- sh -c 'echo [$RALPH_MARKER]'
    []

The pane is the server's child, so it gets the server's environment.
`FOO=bar tty7 run -- …` is a universal idiom and it silently does
nothing here, with no `--env` to reach for instead.

Documented beside the `--cwd` note, with the workaround that does work
— set the variable inside the command, where the shell running it can
see it. Adding `--env` would be a new flag, not a fix.

Checked while here, and correct: a pane does get a usable environment
(PATH, HOME) from the server; `$TTY7_PANE`, `$TTY7_WS` and
`$TTY7_CONFIG_DIR` are all set in a `run` pane as the top-level help
promises; and the payoff holds — `tty7 procs` with no address, run
inside a pane, resolves `$TTY7_PANE` and reports that pane's own
processes.
2026-08-16 08:03:46 +08:00
l0ng-ai 49daef0c79 docs(cli): say where tty7 run runs when --cwd is left off
It does not run where you typed it. The pane is the server's child, so
it starts in the server's working directory — whatever that process was
launched from. Measured, not assumed: `tty7 run -- /bin/pwd` from /usr
and again from /tmp both answered with the directory the server had been
started in.

The help's own example is `tty7 run -- cargo test`, and a server the app
started is not sitting in your project. So the example as written builds
somewhere the reader did not choose. It fails loudly for a build — no
Cargo.toml — but not for anything that would happily run in the wrong
tree.

Documenting rather than changing it. Defaulting a local run to the
caller's directory is the behaviour most callers expect and would make
the example true, but it is a different result for every existing
invocation, and `-m` routes to a machine where the local path means
nothing. That is a decision to take deliberately, not a side effect of a
docs pass.

Checked while here, and correct: `split` inherits the split pane's cwd,
and a pane's reported cwd follows the shell through `cd`.
2026-08-16 08:00:13 +08:00
l0ng-ai 32f893b3b1 docs(cli): ws attach cannot hold what it claims to take
`tty7 ws attach <ws>` answers `{"attached": "<id>", "took_over_from":
null}`, and `tty7 ws ls` shows the workspace unattached a moment later.
Both are correct: an attachment belongs to the connection that made it,
and this command's connection ends with the command.

A caller has no way to tell that from the reply. An agent reading
`attached` reasonably believes it now holds the workspace, and the next
`ws ls` says otherwise with nothing to explain the difference.

So say it. The claim does not outlive the process, that is not a
failure, and what does last is the displacement: the previous holder has
been told, and a dedicated one has been hung up. `took_over_from` is the
part of the reply worth acting on.

Behaviour unchanged — this is the same reasoning the `Attachment` type
already carries ("an attachment belongs to a live connection, and one
read back at boot would name a holder that no longer exists"), said
where the person running the command can see it.

Also checked while auditing, and sound: `tty7 <PATH>` already stats the
directory and refuses a file (`resolve_gui_path`), `ws stop` says "(not
implemented yet)" in its own help rather than only when run, and repeat
`ws detach` is idempotent on purpose for wire-compatibility reasons the
handler documents.
2026-08-16 07:37:53 +08:00
l0ng-ai 9aaf8e28e0 fix(cli): tab new took a --cwd it never checked
Auditing my own earlier fix, the way the last commit's lesson says to.
`run --cwd` and `new <path>` were fixed together and `tab new --cwd` was
missed, so it went on starting the tab's shell in whatever directory the
CLI happened to be run from and answering with a pane id:

    tty7 tab new <ws> --cwd /nonexistent-xyz
    %2

with `pane ls` then showing %2 rooted in the CLI's own directory.

The check is one function now, and the three verbs call it, which is
what should have happened when there were two of them. It refuses before
anything is spawned, and only judges paths on this machine — a routed
`--cwd` belongs to the far side's filesystem.

Pulling it into one place dropped the path from the sentence for a
moment ("--cwd: no such directory", naming the flag but not which of the
reader's paths was refused). The flag is optional in the message and the
path never is, since `new <path>` has no flag to name.
2026-08-16 07:32:47 +08:00
l0ng-ai 3c495e03af fix(cli): say "1 pane", not "1 pane(s)", on the failure lines
Three lines still wrote `pane(s)`: the partial-close report, the
hang-up failure, and the orphan note. All three are reachable with a
count of one, and all three are what a reader meets when something has
already gone wrong — a poor moment to look unfinished.

`pane(s)` is the form a codebase uses when it has not decided, and this
one has: the GUI counts through `t_plural` with one/other branches, and
`pane close` narrates a batch only when there is a batch. These were
what was left.

I said in the doctor fix that "1 panes" was the last of these. It was
not — I had swept the success paths and the format strings that spell
the word out, and these spell it `pane(s)`, so the search missed them.

Checked against a live server: "closed 1 pane; 1 could not be closed"
for one, "closed 0 panes" and "2 could not be closed" for the rest.
2026-08-16 06:57:51 +08:00
l0ng-ai 409334c023 fix(cli): a pane that could not be filed outlived the command
Following the shape found in the split fix to the other two verbs that
have it. Every verb adding a pane spawns it first and files it second,
because the seed carries the daemon's own pane id — there is no other
order. A refusal in that gap ended the command with a shell running that
no tree referenced: absent from `tty7 ls`, present only in `pane ls
--all`, and collectable only with `pane close --orphans`.

`tab new` and `new <path>` both had it. Confirmed rather than assumed:
the mock answers `TabCreate` with a reply `tab new` cannot read, and the
command left `spawned=1, killed=[]`.

One guard for all three sites now, since the ordering is forced and so
the window is permanent. `new <path>` also takes back the workspace it
made on the way in: it holds nothing, and leaving it adds a row to
`tty7 ls` the caller never asked for.

The mock grows a way to fail a chosen control call. Both cleanup paths
are reachable only after something has already been created, so there
was no way to test them from outside.

Verified against a live server that the ordinary paths are unchanged:
`new`, `tab new` and `split` leave the tree and the registry agreeing at
three panes, and `pane close --orphans` finds nothing.
2026-08-16 06:51:53 +08:00
l0ng-ai b89496b9d4 fix(cli): a refused split left a live pane nothing was holding
`tty7 split` spawns the shell and only then asks the tree to hold it, so
any refusal in between leaves a pane running that nothing references —
`pane ls --all` shows it, the tree does not, and `pane close --orphans`
is the only way to be rid of it. Two attempts at `--ratio nan` left two.

NaN is the case that gets there, because it cannot be serialised onto
the control connection at all. The link drops mid-request, so the
daemon's own "a split ratio must be a finite number" never comes back:
the caller sees "control connection lost (request 2)" instead, which
says nothing about the ratio, and the pane is already running.

Refuse a non-finite ratio before the spawn, in the daemon's own terms.
Its clamp to a usable range is deliberate and stays its business — 0,
1 and 5.0 still land on the clamp, as intended; this only refuses what
the daemon would refuse anyway.

Then take the pane back down if the split fails for any other reason
too. Nobody asked for a pane that no tree holds, and leaving it for the
user to find with `--orphans` is not a refusal, it is a mess.

Verified against a live server: two refused splits now leave the pane
count where it was, and a valid `--ratio 0.3` split still works.

Swept the rest of the value-taking flags while here, all sound: `-m` on
an unknown machine, `--key` on an unknown key (which lists the real
ones), `--timeout 0` (exits 124, the conventional code) and a negative
timeout are each refused with the right message.
2026-08-16 06:45:20 +08:00
l0ng-ai 4e6afe25b5 fix(cli): a --cwd that names no directory ran the command elsewhere
tty7 run --cwd /nonexistent -- /bin/pwd
    /Users/thomas/repo/wt/ralph-wc

Exit 0, no warning, and the command ran wherever the CLI happened to be
started from. `tty7 run --cwd ~/porj -- make` builds the wrong tree and
reports success — and the caller most likely to typo a path is a script,
which has nothing to notice it by.

The daemon falls back to a directory that does resolve when the one it
is handed does not. That is right for a cwd inherited from a pane's OSC
7, which is only as good as the shell that reported it, and wrong for
one typed on the command line: an explicit `--cwd` is an instruction,
and one that cannot be carried out has to stop the run rather than be
quietly replaced. Same for `tty7 new <path>`, which rooted the workspace
in the CLI's directory instead and handed back an id as if it had not.

Both refuse before anything is spawned or created — a refusal after the
spawn leaves an orphan pane, and after `WorkspaceCreate` an empty
workspace to clean up. "No such directory" and "not a directory" are
told apart, because they read differently to whoever typed the path.

Only judged for this machine. A `-m` path belongs to the far side's
filesystem, which this process cannot stat — the reason `agent_hooks_state`
already gives for refusing to answer config questions when routed. Asking
the backend, so the two stay consistent.

The mock now defaults to *not* being this machine. It models a Windows
box (`C:\proj`), so leaving it on made two unrelated spawn-ordering
tests judge Windows paths against the host running the suite. A test
about local paths turns it on and uses paths that exist.
2026-08-16 06:37:33 +08:00
l0ng-ai 2ab3b7efbe fix(cli): server start named a pid that had already exited
Six `tty7 server start` at once reported six different pids, five of
them gone by the time they were printed, and every one of them claimed
`"started": true`.

The singleton admits one server and the losers exit immediately — but
they all spawned a child first, and they all then see `running()` go
true, because the winner is up. Each reported the child it had spawned.
`pid` is the field a script keeps in order to watch or stop the server,
so five of six callers were handed a dead one.

Report the pid that is actually serving, read from the pidfile, and say
`started: false` when this call was not the one that started it — the
same answer `start` already gives when a server was up before it ran.
Readable by then: the daemon writes the pidfile after `bind`, and
`running()` needs a request answered, which is later still.

The ordinary path is untouched: a start that wins reports its own child,
because that is the serving pid.

Verified by racing six starts against an isolated config dir — all six
now report the one live pid, exactly one says it started it — and by a
single start, which reports the pid `ps` shows for that config dir.

Also checked while looking for this, and sound: a truncated, garbage,
empty or wrong-schema machine.json is quarantined (`.corrupt`, then
`.corrupt.N`) and the server starts clean; a killed server's pane
children do not leak, since closing the pty master hangs them up.
2026-08-16 06:19:51 +08:00
l0ng-ai a78b85d3ca fix(cli): an address in the wrong slot, and two pointers that led nowhere
Found by running every `tty7 …` command the help and README recommend.

`tab ls @7` answered "no workspace named '@7'". `@7` is a well-formed
tab address typed into a *tab* command, and its own siblings `tab close
@7` and `tab rename @7` both answer "no tab @7" — only `ls` takes a
workspace in that slot, so the sigil looked like the problem instead of
the slot. `parse_workspace` accepts any word at all, unlike `parse_pane`
and `parse_tab` which each validate their own sigil, so nothing noticed.
Name it for what it is, `%` included. The hint is only reachable after
the lookup has already failed, so a workspace someone really did name
`@7` is found first and never sees it.

Then, checking my own new message did not recommend a dead end — the
mistake this same sweep caught last time — it did: `tty7 ls` renders
WORKSPACE/NAME/TABS/PANES/ATTACHED and has no @ column at all, so it
cannot answer "which workspace holds @7". The pre-existing "no tab @7 —
`tty7 ls` shows the @ numbers" was wrong the same way, and has been
since before this branch.

`tty7 pane ls` is the listing that actually carries panes, their tabs'
@ numbers and the workspace holding them, so all three now point there.
Verified against a live server: every message, and the command each one
recommends.

Also checked and found sound, so it is not re-swept: --json is a single
valid object for all ten listing verbs, errors are empty stdout plus a
message on stderr with rc=1, and a mistyped subcommand is named as one
rather than offered to the GUI (deliberate, and tested).
2026-08-16 06:04:57 +08:00
l0ng-ai 335d98252c fix(cli): the advertised wait idiom fails in one of its own end states
`tty7 wait`'s headline example is

    tty7 wait %3 && tty7 capture %3 --plain

and `exit` is one of the three states `--until` waits for by default. Run
it that way and the pane is gone by the time capture runs: wait reports
`exit` and succeeds, `&&` proceeds, and capture answers that nothing is
running. An agent following the documented pattern gets no output for a
command that produced some.

The example is right for an agent — `waiting` and `done` leave the pane
alive. It is wrong for a plain command, which finishes by the shell
exiting. Spell both out in `wait`'s long help, and say plainly that
`exit` is the end state with nothing left to read.

Verified against a live server: `wait --until free` blocks for the
command (5s for a 4s sleep), and the capture after it finds the marker.

Also: a server running one pane had `doctor` say "1 panes" on its first
line. It is the first thing a new user runs. Every other count in the
tree is already plural-aware — the GUI routes these through `t_plural`
with "one"/"other" branches, and the CLI's other count (`closed N panes`)
handles the single case separately — so this was the last one.
2026-08-16 05:57:48 +08:00
l0ng-ai a343915734 fix(cli): a pane the tree still holds is not a pane that does not exist
Found by running the CLI rather than reading it. `tty7 run --keep` files
its pane into a workspace and the tree keeps that leaf after the command
exits — `tab ls --json` goes on reporting `panes: [31]`. But every
`%PANE` verb answered:

    no pane %31 on this machine — `tty7 pane ls --all` lists them

which is wrong twice. The pane *is* on this machine. And `pane ls --all`
is documented as "every pane the server runs", so the command it sends
you to is the one guaranteed never to show this pane — the user follows
the advice, sees nothing, and has no next move.

`or_no_such_pane` consulted only the running registry, which cannot tell
"never existed" from "not running". It now asks the tree once it already
knows the pane is not running, and answers accordingly:

    pane %31 is not running — ivory-heron still holds it, so there is
    nothing to read or type into. `tty7 tab ls 1790a260` shows it;
    opening the workspace in the GUI revives it.

The tree is fetched only on a path that has already failed, so no
ordinary call pays for it, and an unreadable tree falls back to the
plain sentence. A genuinely absent pane is unchanged.

Verified against a live daemon on an isolated config dir: both messages,
both exit 1, and the recommended `tab ls` really does list the pane —
having just fixed one dead-end recommendation, I checked the new one.
2026-08-16 05:49:11 +08:00
l0ng-ai 5de8fd552e fix(cli): check the inherited workspace before run --keep spawns
`run --keep` resolves `--ws` against the machine tree before it spawns
anything, and takes `$TTY7_WS` on trust — it only parses the id. Filing
the pane happens after the spawn, so a shell whose workspace has since
been removed, or one opened against another machine, started the pane and
then failed to file it. The pane kept running with nothing holding it.

Measured against a live daemon: with a `$TTY7_WS` this machine does not
have, `run --keep` exited 1 with a clear message and left a `sleep`
behind, `pane ls --all` reporting `"orphans":1`. With `--ws` spelled out,
the same bad id spawned nothing.

Recoverable — `pane ls --all` finds it and `pane close` ends it, both
documented — but nobody asked for the pane, and `$TTY7_WS` is the
*documented default* for `--keep`, so this is the ordinary path rather
than an exotic one.

Resolving the inherited id is now the same check the explicit one gets,
and only when `--keep` needs it: a plain `run` uses the workspace as an
ownership stamp, where a stale id costs nothing and the round trip to
find out would.

The spawn still precedes the filing — `TabCreate` names the pane the
daemon assigned, so it has to, and
`run_keep_spawns_first_then_files_the_pane_into_the_workspace` pins that.
What moved is only where the workspace is checked.
2026-08-16 04:56:50 +08:00
l0ng-ai df8432f0af fix(cli): one answer for a pane that is not there
Three verbs, one condition, three sentences:

  procs %99    no pane %99 on this machine — `tty7 pane ls --all` lists them
  capture %99  observing pane %99: daemon refused Observe: no such pane 99
  send %99 hi  sending input to pane %99: no such pane 99

The daemon's own refusal makes a fine diagnostic and a poor sentence: it
names the wire request rather than the verb that was typed, so `capture`
told the user about "Observe", a word that appears nowhere else they can
see. This is the CLI agents read stderr from, and it was reporting one state
of the world three ways.

They now all answer with the line `procs` already used. The registry is
consulted only once something has failed, so the ordinary path still costs a
single round trip, and a failure with the pane present keeps its own words --
this must not swallow a connection error and call it a missing pane.

`resolve::no_such_pane` is now that sentence's one definition; the copy in
`workspace_of_pane` had drifted to suggesting plain `pane ls`, which does not
list the orphaned pane most likely to be asked about.

Found by running the verbs against a live daemon rather than by reading:
every one of these paths looks right in isolation.
2026-08-15 21:55:10 +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 d5368807c1 fix(cli): say the server is down instead of "No such file or directory"
With no daemon running, most verbs give the message that helps:

  tty7 ls          could not reach the tty7 server on this machine —
                   `tty7 server start` brings one up

The three that go to a pane instead of to the control socket did not:

  tty7 send %1 hi  sending input to pane %1: No such file or directory (os error 2)
  tty7 capture %1  observing pane %1: No such file or directory (os error 2)
  tty7 procs %1    No such file or directory (os error 2)

All three read as if the *pane* were missing, or some file — and `procs` gave
no context at all. Panes are reached over the daemon's own socket, and with no
daemon there is no socket, so the connect fails with a bare `NotFound` that
each caller then wrapped in its own words.

`local_control` has contexted its connect all along; this is the same courtesy
one socket over. The local pane client is probed once when it is built, so all
three now say what `ls` says.

Local only, deliberately: the message names *this* machine, and a routed client
would pay a round trip across the link to be told something untrue of the far
end. Cached with the client, so it costs one round trip per process.

Nothing real is swallowed — a daemon that is running answers a bad pane id with
its own message, and still does:

  send %999      sending input to pane %999: no such pane 999
  capture %999   observing pane %999: daemon refused Observe: no such pane 999

Checked against a live instance both ways, plus a full send/capture round trip
on a real pane.

2949 tests pass.
2026-08-15 18:57:53 +08:00
l0ng-ai 768aca01f1 fix(cli): machine ls --json was missing the machine it runs on
The table synthesized the `local` row itself while `--json` serialized the
server's routes alone, and a route is a link to some *other* machine. So on a
machine with no remotes — which is every machine until someone connects one —
`tty7 machine ls` printed a row for itself and `tty7 machine ls --json`
answered `{"machines":[]}`. An agent enumerating machines read that as "there
are none", including the one it was running on.

The docs already said what it should be: "the local machine plus every link",
with `{"machines":[{"key","kind","connected"}]}`.

The list is now assembled once in `machine_ls` and handed to both renderings,
so the two cannot disagree again; `routes_table` renders what it is given
instead of adding a machine to it. Pinned by a test that asserts the table and
the JSON agree, with and without a link.

Found by running the command against a dev instance rather than by reading it —
the human output looked right, and it was the half nothing prints by default
that was wrong.

2934 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai e530d5f778 docs(rustdoc): fix the links that pointed nowhere, and gate rustdoc in CI
Nothing had ever run `cargo doc`, so 16 warnings had collected. Four were
links to items that do not exist, and two of those were worse than a dead
link: `git_badge` and `info_chip` documented their sizes in terms of
`PANEL_TEXT` and `PANEL_TEXT_META`, px constants deleted when the interface
font scale landed. The module comment twenty lines up already says they went;
the prose downstream still derived pixel arithmetic from them, so a reader was
being told the pill is 20px tall against a 19px neighbour when both are now
rems that move with `ui_font_size`.

Rewritten against the ladder that exists (`META_MONO` beside `TEXT_MONO`), and
`info_chip`'s comment now records what its own numbers imply: its padding and
radius are pixels wrapped around rem-sized text, so the two stop agreeing once
the interface scale leaves 100% — the same trap `PIP_SIZE` right below it is
written in rems to avoid. Left as a note rather than changed, because that is a
visual decision and this cannot see the result.

The other ten are `private_intra_doc_links`, and that lint does not apply here:
it exists so a *published* crate does not ship docs whose links dead-end, and
all four crates are `publish = false`. Allowed at the crate root with that
reason, because a public item explaining how it relates to a private one is the
useful half of these comments.

The `clippy` job becomes `lint` and runs rustdoc too, on the same warm cache.
Still non-required.

2932 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai c957231142 chore(lint): put clippy on the CI gate, and clear the ~200 findings behind it
CI checked `cargo fmt --check` and the build, so nothing ever read the
content of the code — ~200 clippy findings had accumulated, a third of them
in `#[cfg(test)]` modules.

Two of them were real:

- `resolved_background_appearance` took `backdrop`, and only the
  `#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy
  asks for and compiles cleanly on macOS; on Windows it is an undefined
  name. Kept the parameter and discharged it in the non-Windows arm the way
  `package_for_current_install` already does.
- `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were
  carrying translations in three languages for strings nothing reads.

The rest is mechanical: let-chains for collapsible `if let`s, struct-update
syntax for `Default::default()` reassignment, `sort_by_key` where a manual
reversed comparator was doing the same job the same file already did with
`Reverse` two functions later.

Where clippy was wrong, the reason is now in the tree rather than rediscovered:
per-platform `#[cfg]` blocks each keep their `return` (dropping it only
compiles on whichever target's block lands last), the loopback parser keeps
three parallel arms instead of folding one into a `?`, and the four wide enums
are all built on the stack and consumed immediately, so boxing them would add
an allocation rather than save one.

`L10nKey` cannot be clean under `dead_code` on any single platform, so the
allow there records how to audit it instead — which is how the eight dead keys
were found.

`english()` gained the cross-platform test its doc comment already claimed;
only the Windows hint had been pinned.

The clippy job is non-required until it has green history, for the same
branch-protection reason `host-boundary` documents.

2930 tests pass.
2026-08-15 18:11:17 +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 49901d7f8a fix(cli): let --enter press the key it is shorthand for (#581) (#606)
`--enter` is documented as sugar for `--key enter`, but the send dispatch
counted only `args.keys`, so `tty7 send %42 --enter` answered "needs TEXT
... or a --key to press" and pressed nothing. The key list is now built
before the dispatch and the dispatch counts it, so a marked address with
`--enter` and nothing else runs what the pane already has typed, and a
bare `send --enter` presses Enter where the caller sits.

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:24:58 +08:00
l0ng-aiandl0ng-ai 100904a313 fix(switcher): name another workspace's tabs from their terminal titles (#558)
A window names its own tabs from its live terminals' OSC titles; every
other workspace it lists it reads out of the machine tree, which recorded
each pane's foreground process name and never its title. So the naming
fell through to the agent, and switching workspaces — which happens in
place and drops the terminals the window was reading — turned the tabs of
the workspace just left into a column of identical "Claude Code" rows.

The daemon now sniffs OSC 0/2 and records the title beside the pane's cwd,
capped at 256 characters, and `TabLabel` ranks it second only to a name
someone gave the tab. The switcher puts it through the same abbreviation
the tab strip uses, so a shell's `user@host:~/dir` reads `…/dir` in both
places, and in a split the pane running an agent names the tab rather than
whichever shell happens to be first. `tty7 tab ls` and workspaces on a
remote machine were reading the same missing field and are named the same
way now.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 13:52:17 +08:00
l0ng-aiandl0ng-ai b7196ae49a Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 09:58:47 +08:00
l0ng-aiandl0ng-ai 27880c0f14 feat(cli): wait on commands, press keys, and reap orphan panes (#505)
* feat(cli): wait on commands, press keys, and reap orphan panes

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 20:38:27 +08:00
l0ng-ai c138be687a fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started,
both of them things the tree was the only possible place to keep.

**The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh
spec and its agent, but never what it was running. A window rebuilding a
dead pane from the tree therefore had nothing to pass and spawned on
whatever the default shell is now — so a restart turned a bash pane into
a PowerShell one, quietly and in place. The daemon resolves the override
against the config at spawn time and is the only party that knows the
answer, so it keeps it and reports it; the seed carries it too, for the
panes a window spawned itself. A handoff carries it in the blob, because
nothing on the far side of an `execve` can work out the command line of
a child it never spawned.

**The screen.** The startup sweep ran before the endpoint was listening,
which is the one moment nothing can answer the question it asks: the
registry is empty and the windows that know which screens are still
wanted cannot say so yet. A tree that failed to parse made it worse —
`read_machine` quarantines it and returns an empty `Machine`, so one bad
file took every pane's stored screen with it. The sweep now happens only
on the periodic pass, a tick later, with the registry filled in and the
tree caught up; nothing is serving a request in between. Turning the
setting *off* still clears the directory at once, because there the
promptness is the whole promise.

Two smaller ones alongside it: `restorable_pane_ids` now counts the
tree's pane list and not only the panes some tab currently stands on —
the two disagree while a window is between layouts, and being wrong
costs a file swept a tick late in one direction and somebody's terminal
in the other. And `restored_screen` drops the snapshot file *after*
deciding it was not empty, so a snapshot holding nothing is no longer
consumed by the request it could not answer.

The restore path had no end-to-end test, which is how this shipped: the
unit tests cover the file, not whether a window that reattaches is shown
anything. The new one runs a real daemon, puts a marker on a real pane,
stops the daemon, starts another, and reads the wire.
2026-08-10 16:06:54 +08:00
l0ng-aiandl0ng-ai 9815f2d16f fix(windows): restore Ctrl+C in panes (#459)
The daemon was created with CREATE_NEW_PROCESS_GROUP, which disables
Ctrl+C for the whole new group — and Windows hands that "ignore Ctrl+C"
state down to every descendant. Every ConPTY shell a pane spawned
inherited it, and so did everything those shells ran: the pane wrote 0x03
and conhost turned it into a keypress, but the CTRL_C_EVENT never came,
so `go run` and `npm install` carried on. Git Bash looked fine only
because MSYS synthesises SIGINT from the byte itself and never waits for
a console event.

DETACHED_PROCESS already leaves the daemon without a console for a
control event to arrive on, so the group flag bought nothing to begin
with. Both daemon spawn paths and tty7-cli's headless server — which
spawns panes too — now share one constant without it, and
DaemonPane::spawn clears any inherited ignore before it opens the pty, so
a tty7 launched from a shell that already had the bit set is covered as
well.

The regression test has to inherit the state rather than switch it on in
place, since that is the shape the daemon was in: an intermediate process
created exactly as the daemon used to be runs both arms, and a pane must
be interruptible only after the clear. Its observable is the shell rather
than the interrupted command — after the ^C, cmd gets its prompt back and
acts on the `exit` typed behind it — so it reads no message and holds on
a non-English Windows.

Fixes #451
Fixes #314

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 13:24:23 +08:00
l0ng-aiandl0ng-ai 8dbc7efa1a fix(cli): stamp panes with the workspace that holds them, not the client's name (#425)
* fix(cli): stamp panes with the workspace that holds them, not the client's name

A pane's owner names the workspace allowed to attach to it. The CLI wrote
a literal "tty7-cli" there for every pane it made, so a window opening on
a CLI-built workspace found none of them attachable: it spawned a fresh
shell for each tab, orphaned the live ones, and — because the tree still
carried each pane's agent session — greeted the user with a failing
`claude --resume <id>` in every one of them.

Both spawn paths now pass the workspace id, and restore treats an owner
that parses as no workspace as no claim at all, so panes already stamped
by an older CLI attach instead of stranding.

* fix(cli): let the OWNER column speak only when it disagrees with WS

Now that a pane's owner is the id of the workspace holding it, printing
both spells the same id twice on every row of `pane ls --all` — and
buries the rows that matter. The column now shows a dash when the two
agree, so what is left is exactly what is worth reading: a pane its
holder may not attach to, and an orphan still naming where it belongs.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 20:08:03 +08:00
l0ng-ai 09a653d10b fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction.

- The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one.
- for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them.
- finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id.
- A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart.
- Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace.
- tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column.
- tty7 new --open raises a window on the workspace it just made.
2026-08-09 14:18:19 +08:00
l0ng-aiandl0ng-ai a89d9ea45b fix(cli): diagnose unavailable agent hooks (#321)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-04 07:23:20 +08:00
l0ng-aiandl0ng-ai 7c9a2d20b6 fix(cli): terminate panes when closing tabs (#319)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-04 07:06:31 +08:00
l0ng-aiandl0ng-ai e8525e1b33 fix(cli): submit send enter outside paste bursts (#322)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 16:55:48 +08:00
b7e08c7e11 feat(windows): add optional windows explorer context menus (#310)
* add CLI support for opening directories in new tabs

f

* feat(windows): add optional windows explorer context menus

f

* fix(gui): restore missing windows and reject lossy paths

* fix(windows): harden explorer menu registration and native path handling

* fix(cli): preserve native GUI paths on Windows

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 15:23:59 +08:00
ARNO 9b62ef3f16 feat: add CLI support for opening directories in new tabs (#308)
* add CLI support for opening directories in new tabs

f

* fix(gui): restore missing windows and reject lossy paths
2026-08-03 13:33:15 +08:00
l0ng-ai b6c0d2636a fix(cli): make tty7 wait edge-aware and liveness-aware
Three holes in the wait primitive, all of which make a delegation loop
answer with something other than what it asked for.

The agent status the server keeps is a level, not an event: `done` stands
until the next turn begins, `waiting` until the agent moves again. A wait
issued right after a `send` therefore returned last turn's state before
the worker had even read the input, and a second task in the same pane
matched `--until done` instantly. `--changed` snapshots the position the
wait arrived at — status plus the activity counter, which ticks even when
the status letter does not — and refuses to match it; the JSON carries
`stale` so a plain wait can tell whether the answer might be a leftover.

`exit` was unreachable for any pane that had ever had an agent: the
snapshot has no liveness in it, and the daemon keeps a dead pane
registered until it is closed. A worker that crashed mid-turn reported
`working` until the timeout. Liveness is now re-checked from the tree
every few polls; the fast path where the first poll already answers still
costs exactly one request.

The "pane exited before reaching the awaited state" branch built its JSON
and then threw it away on an anyhow error, leaving `--json` with nothing
to read and the exit indistinguishable from an unreachable daemon. It now
exits 1 with its report.

Also: the wire spelling of a state is written out instead of derived from
the variant name, the sleep no longer overshoots a near deadline, an
absurd `--timeout` cannot overflow, and `--interval` is range-checked at
parse time rather than silently clamped.
2026-08-02 09:49:47 +08:00
yetoneandl0ng-ai e6bdf44f3c feat(cli): session CLI for scripting and agent orchestration (#248)
* feat(cli): `tty7 wait` + the agent-coordination note

The two pieces of the original session-CLI PR that main's own CLI
doesn't cover, rebuilt as a minimal delta against it.

`tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's
agent reaches a requested state — the orchestration primitive that lets
one agent sleep until its peer blocks on a permission prompt or
finishes a turn, instead of screen-scraping. A poll of `AgentStates`
rather than an `events` subscription on purpose: a one-shot stateless
question composes into scripts, survives a server restart mid-wait, and
needs no cursor management. Agentless-but-live panes read as idle via
the machine tree; a dead or vanished pane reads as exit, which ends
every wait (matched only when asked for). Timeout exits 124, the
`timeout(1)` convention.

The coordination note is discovery for the whole CLI: a marked,
idempotent block describing the verbs, installed into
~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and
~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your
agents coordinate?" prompt fires the first time a pane detects a coding
agent; a Settings → Agents switch drives the same install/remove, with
state read from the files themselves. Uninstall strips exactly the
marked block; an unterminated block is left alone rather than truncated
at a guess.

* feat(agents): replace the global note with an orchestration skill

Per review: global instructions tax every session's context and hand
every agent — workers included — the ambient authority to orchestrate
its neighbours. The common shape is primary → workers: one agent owns
decomposition, dispatch, waiting and aggregation; workers just do
bounded tasks.

A Claude Code skill fits that exactly. `core::orchestration_skill`
installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its
one-line description rides in context until the user or the primary
agent explicitly invokes it, and workers never see it. The body can
therefore afford the full delegation loop (tab new → send → wait →
answer-or-capture → pane close) instead of a token-starved cheat
sheet.

The file is wholly tty7-owned: install is a plain overwrite (also the
version-refresh path), and uninstall keys on an ownership marker so a
user's hand-written skill under the same name is refused, not deleted.
Gone with the global note: the first-agent-detected prompt, its config
flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents
switch now drives the skill install instead.

---------

Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-02 09:37:09 +08:00
ayamir 3847e4d015 fix(ui): show process names in sidebar titles 2026-07-31 23:00:53 +08:00
l0ng-ai 86eba1e2c2 feat(cli): make a captured pane readable, and stop panicking on a closed pipe
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.

`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.

The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.

The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.

Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.

alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
2026-07-31 19:18:44 +08:00
l0ng-ai 95ceed533d fix(cli): name a mistyped subcommand instead of offering it to the GUI
`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)".
Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands
in that positional — every typo came back as an offer to open a directory named
after it.

A word with no separator, no leading ./~, and no matching file on disk is a
mistyped verb, and is now reported as one. Real paths still reach the launcher
and fail there for the honest reason.

Also: `ws`'s help said "the named session trees", missed by the session -> shell
pass, and long_about still advertised $TTY7_SOCKET, which no longer exists —
it is $TTY7_CONFIG_DIR now.
2026-07-31 15:07:03 +08:00
l0ng-ai 8000461706 fix(core): derive both endpoints from the config dir, publish the dir itself
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken
against any normally-installed server — the CLI's entire hot path. Only the
control verbs worked.

Two endpoints, two rules. The pane socket came from the config dir; the control
socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 —
under the same basename, `daemon.sock`. So they were told apart by directory
alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with
with_file_name: on the default layout that returns the input unchanged. Pane
verbs dialed the control socket and the daemon hung up on them. A --config-dir
server was worse: it published the *default* control socket to the shells it
spawned, so a CLI inside an isolated instance drove a different server.

The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK
explicitly, placing both endpoints in one directory under different names — a
layout production never produces. It had removed the bug's precondition.

Now: the control socket is derived from the config dir like the pane socket
(control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port,
with -control on the hashed fallback so the two cannot collide), and panes are
handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so
ControlClient::connect and PaneClient::local resolve the same two sockets the
server opened, through the same functions. No second derivation to disagree.

remote_link's remote_control_socket was a third copy of the old rule, used to
locate a remote server's endpoint before connecting; it follows the config dir
too, and the env probe now reads $TTY7_CONFIG_DIR.

Drops the CLI's server-lifecycle guard: stop/start already follow the config dir
through transport::connect and --config-dir, so there is no longer a mismatch to
refuse. The e2e case that covered only `status` over a lone variable now also
runs a pane verb — the asymmetry it missed is exactly what broke.

Note: this moves the control socket for existing installs. A running pre-change
daemon will not be found at the new path, which is the honest outcome — its
control dialect is v3 against this build's v4, so reaching it only produced a
version error anyway.
2026-07-31 14:41:29 +08:00
l0ng-ai 54f498aa6c fix(cli): surface orphan panes, answer --json everywhere, scope the server verbs
An interrupted `tty7 run` leaves its pane running with nothing referencing it:
no workspace holds it, every listing walks the tree, and the orphan sweep only
logs. `pane ls --all` reads the server's registry instead and marks what nothing
holds; `pane close` falls back to hanging a pane up directly when no workspace
can route a PaneClose, so an orphan is stoppable.

--quiet silenced failures as well as successes, leaving a bare exit code and
nothing to debug; it now suppresses only output on success, and covers --json
too. `run` exited through a path that skipped the report entirely, so
`run --json` printed nothing at all; it now carries its report, with
exit_code_known distinguishing the command's own 1 from the stand-in 1.

The server lifecycle verbs can only drive the default endpoint — spawn::stop
dials transport::connect() — while every other verb follows $TTY7_SOCKET. They
now refuse when that names a different endpoint rather than acting on a
different server than `tty7 status` reports on.

Also: tables pad by display width, so a CJK name or path no longer skews every
column after it; --h/--v become --horizontal/--vertical with the short forms
kept as aliases; the verbs that are not implemented say so in --help instead of
only at runtime; capture's help admits it decodes as lossy UTF-8.
2026-07-31 13:05:02 +08:00
thomasandClaude Fable 5 794ae89d24 feat(cli)!: drop the attach verb and the design doc
The CLI's user is the coding agent; run/send/capture/events cover it.
Workspace-level ws attach/detach stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:36:25 +08:00