Commit Graph
1329 Commits
Author SHA1 Message Date
l0ng-ai c69a39f7a0 docs(changelog): five more, bringing the backlog to a third
A clamped setting said nothing, so a config file asking for `font_size: 999`
went on claiming it while the app ran at 256. `tty7 procs` on a pane that
never existed printed "nothing running in this pane" and exited 0, which an
agent cannot tell from an idle pane. A terminal title carrying a newline
painted its tail over the tab strip. The refusal to write the machine file
named neither the operation nor the file, on a machine where that directory
is what the reader has to go and fix. And the clipboard-read policy was an
upstream crate's default rather than tty7's own decision, one release away
from handing any program in a pane whatever is on the clipboard.

Thirty-eight of the branch's 114 user-visible fixes are now written up, which
is the share 26.8.3 shipped with. The rest are smaller — wording, log lines,
internal guards — and can go in as they are reviewed.
2026-08-23 05:38:56 +08:00
l0ng-ai ecbe43f5ab docs(changelog): four more, one of them the largest on the list
Every pane ran with SIGTERM blocked. The daemon blocks it on its main thread
so one waiter can run the shutdown save, and that mask crossed `fork` and
survived `execve` — so `kill`, `pkill`, `timeout`, a supervisor and a CI
cancellation could not stop anything running in a pane, and only `kill -9`
would. That is the biggest thing still missing from these notes.

With it: panes the window put down while squaring its layout were never hung
up, because that path dropped the daemon's reply; a killed server left its
throwaway shell directories behind, 3,850 of them on this machine; and
`send`, `capture` and `procs` reported a raw `os error 2` instead of saying
the server was down, unlike every verb that goes to the control socket.

Thirty-three written up. No code changes.
2026-08-23 05:37:23 +08:00
l0ng-ai 58145dace1 docs(changelog): four more, three of them things a user would see
A newline in a filename, a remote name, or a completion description broke the
row drawing it — the layout breaks on a newline whatever the row asks for, so
the row grew and everything below it slid. 122 of the shipped completion specs
carry one. A tab path starting at `~` was drawn with a leading ellipsis that
stood for nothing, while an absolute path of the same shape kept its root. An
internationalised domain came back as the bare scheme `https://`, offered as a
clickable link that opens nowhere. And the PNG decode ceiling on the
untrusted graphics path was the image library's 512 MiB default rather than
tty7's own, in the GUI process.

Twenty-nine written up. No code changes.
2026-08-23 05:35:50 +08:00
l0ng-ai 12ab786e64 docs(changelog): five more, including two nobody could have noticed
Search with case sensitivity off found nothing when the query carried a
capital, because the regex engine's own smart-case rule decided instead of the
toggle — so the toggle looked broken exactly when someone typed `Error`. The
SCM confirmations answered neither Escape nor Return outside English, and put
Cancel where every other dialog puts "do it"; those are the dialogs guarding
discard, reset and amend. Quick connect read a typed `-p 22` as silence and
connected to whatever the alias said instead. A workspace's port forwards
could stay bound for the life of the daemon with nothing logged, because an
unaddressable route and an unanswered request both reported a clean teardown.
And the published `tty7 wait && tty7 capture` idiom came back empty whenever
the pane exited, which is one of the three states `wait` waits for by default.

Twenty-five written up. No code changes.
2026-08-23 05:34:12 +08:00
l0ng-ai fa392a9558 docs(changelog): write up five more fixes, including two that lost work
Continuing the backlog. These five are the ones a reader of the release notes
would most want to have been told about:

macOS in-app updates failed 100% of the time, on every build and every
channel, because the updater read `codesign`'s designated requirement off the
wrong stream — the only way out was downloading the app by hand. Closing a tab
threw away unsaved editor edits with no dialog, by the everyday route: edit a
file, click back into the terminal, press ⌘W. A client opening a remote
workspace could rename somebody else's — in the reported case one holding
nineteen live panes. The ignore chain let a whitelist inside an excluded
directory undo the exclusion, which git does not, and did not read
`.git/info/exclude` at all. And `tty7 events` exited 0 when the server went
away, so a reader whose lines stopped had no code to branch on.

Twenty of the backlog written up so far; the list still runs longer than this.
No code changes.
2026-08-23 05:32:00 +08:00
l0ng-ai d2a894c4a1 docs(changelog): write up ten fixes that shipped without an entry
This branch carries 114 user-visible fix commits and had added six
changelog bullets. Recent releases run between a third and nearly all of
their fixes — 26.8.3 wrote up 85 of 251, 26.8.2 wrote up 71 of 74 — so five
percent is well under what this project does, and the ones missing are not
small.

Ten of the largest, in the order someone would care about them: a `kill -9`
leaving the server unable to start again and no way to find out why; SIGTERM
armed too late to save any pane's screen, so a shutdown or a logout cost every
one of them up to thirty seconds; an SFTP download renaming over a file it did
not pick, and a dangling symlink reading as a free name; a failed update
deleting the only backup and leaving nothing at the install path; `--cwd`
naming a directory that does not exist and running the command somewhere else
with exit 0; a probe timeout disabling shell integration for a whole host for
the life of the daemon; a signalled command reported to agents as an honest
exit 1; a refused split leaving a shell nothing holds; a window reshape
leaving its old shells running; and three secret-carrying types still
reaching the log.

No code changes. More to follow — the rest of the list is longer than one
sitting.
2026-08-23 05:29:52 +08:00
l0ng-ai 16370ed30a docs(cli): record what the capture settle timer actually costs
`tty7 capture` takes ~323ms against a local daemon and neither constant said
why. Measured: a pane that has printed nothing costs the same as one holding
22,000 lines, `--plain` costs the same as raw, and lowering the replay grid's
floor from 10,000 lines to 100 changes nothing. The grid work is ~20ms; the
rest is `REPLAY_SETTLE` running out. `tty7 pane ls` over the same socket is
~5ms.

It is paid because the replay has no end. The daemon sends `Size` then
`Snapshot` per ring segment and stops, and the only way to know it has stopped
is to wait. Live `Output` cuts the wait short, so a busy pane returns at once
— but the idiom the reference recommends is `tty7 wait … && tty7 capture …`,
and `wait` returns exactly when the pane has gone quiet. The recommended path
always pays in full.

No behaviour change; this is the note that was missing. It also records the
two wrong ways out, because both look attractive from the outside. Lowering
the timer trades a bounded delay for silently truncated output on a slow link
or a loaded machine. An end-of-replay message is the real fix and is *not*
additive — `DaemonMsg::read` rejects an unknown kind outright, so a daemon
sending one to an older client breaks it. That needs a `PROTOCOL_VERSION` bump
with the daemon gating on what the peer announced, which is a larger change
than a comment.
2026-08-23 05:22:47 +08:00
l0ng-ai a77d691dad fix(cli): send a bad tab address to the table that has the @ numbers
Five places tell someone where tab `@` numbers come from, and they had drifted
to three different answers. The three `--help` strings say `tty7 tab ls`. The
two runtime errors — the ones a person actually meets, because they only
appear once something has already gone wrong — said otherwise.

`'@deadbeef' is not a tab address — @7 as numbered by `tty7 ls`` was simply
wrong. `tty7 ls` lists workspaces: `WORKSPACE NAME TABS PANES ATTACHED`, where
TABS is a count. There is no `@` anywhere in it. Someone following that
instruction finds nothing and has to go looking.

`no tab @N — `tty7 pane ls` shows the @ numbers` was true but sideways.
`pane ls` does carry a TAB column, but the verb the person is already holding
is `tab`, and `tab ls` is the table with one row per tab and the number in the
first column.

Both now say `tty7 tab ls`. The guard reads all three sources for anything
teaching where the numbers come from and requires that answer, so a sixth
place — or a sixth answer — is caught rather than assumed. It skips its own
test module, which names the phrases it searches for.
2026-08-23 05:10:37 +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 719e35ec59 test(machine): pin the tty7 events kinds to the CLI reference
`docs/cli/reference.mdx` says of the event stream that "the kinds and payloads
are stable; the prose is not" — a promise to whoever is writing a loop against
it. The stream is externally tagged, so a reader matching on known kinds meets
an object it cannot place the moment a `LayoutDelta` variant is added without
the page being told.

All thirteen currently agree, in both directions. Nothing was holding them
there.

Names come from the enum rather than a list kept beside the test, so it cannot
quietly agree with itself, and only the sentence that enumerates the kinds is
read — a name appearing elsewhere on the page does not count. The scan asserts
it found more than ten variants first, so a change in how the file is written
fails loudly instead of passing for the wrong reason.

Checked both ways: adding a variant, and removing a kind from the page.
2026-08-23 05:02:58 +08:00
l0ng-ai 8390fa54e2 test(sftp): pin the one thing only the components walk refuses
`safe_local_name` checks a remote-supplied filename three ways: literal `.`
and `..`, any `/` or `\`, and finally that `Path::components` sees exactly one
`Normal`. Deleting either of the first two left every assertion passing —
they cover each other on Unix, which is defence in depth working rather than a
fault.

The components walk is different. On Windows it is the only one that refuses a
drive-relative name, and `Path::join` with one does not append, it replaces:
`…\Downloads`.join(`C:evil.txt`) is `C:evil.txt`, resolved against the
process's current directory on that drive. The name comes off a remote
directory listing, so the server picks it, and the download would land outside
the folder it was told to write into. Nothing asserted that.

Now something does, under `#[cfg(windows)]`, with a `#[cfg(not(windows))]`
counterpart asserting the opposite — because the answer genuinely differs: on
Unix `C:evil.txt` is an ordinary filename with a colon in it and refusing it
would be wrong. That difference is precisely why the decision goes through
`Path::components` instead of a list of forbidden characters, and the pair
says so.

Stated plainly: on Unix the components walk has no observable effect that the
other two checks do not already give, so no test running here can pin it. The
Windows half is where it earns its place, and CI is where that half runs.
2026-08-23 04:57:30 +08:00
l0ng-ai 3e1b8bd620 test(agent-hooks): pin that the hook is inert outside tty7
Removing the `TTY7` marker check from `run_agent_hook` left every test in the
repo green. That check is the whole of the promise the generated hooks make:
they are installed into the user's own Claude, Codex, Gemini and Copilot
config, so they run on *every* invocation of those agents — including the ones
in iTerm, Terminal, VS Code, or over plain ssh. What they emit is
`OSC 777;notify`, urxvt's desktop-notification convention, honoured as such by
more than one terminal. Without the check those terminals would get a
notification, or a mouthful of escape bytes, every turn of an agent that has
nothing to do with tty7.

The three plugin bridges carry their own `process.env["TTY7"]` gate and are
already tested for it. The nine config-driven agents have only this one.

Order is pinned as well as presence: the check sits ahead of the stdin read
because stdin is a pipe the caller may never write to, and a hook that blocks
is a hook that hangs the agent.

Checked at source rather than exercised. Exercising it means varying an
environment variable, and this suite runs in one process across many threads
where `set_var` is unsound; a three-line gate is not worth making the rest of
this file unsafe to test. Verified against both mutations — removing the check,
and moving it below the stdin read — each named precisely.
2026-08-23 04:31:25 +08:00
l0ng-ai a9faf7cf07 test(ssh): pin the two host-key decisions nothing was checking
Mutating the host-key policy table showed it is well covered: accepting a
revoked key with verification off, and silently accepting an unknown or a
changed key, each fail the suite. The carrying-out was not.

**Saying no had nothing holding it.** Deleting the `if !accept` guard in
`apply_decision` — so a person shown a changed host key declines and connects
anyway — left every test in the repo green. `accepted_and_remembered` is
tested on its own; nothing checked that the caller acts on its answer. Now
four responses are refused: no, no-with-the-checkbox-still-ticked, cancelled,
and a response of the wrong shape. It touches no filesystem, because none of
them reach the recording branch.

**Dropping the superseded line had nothing holding it either.** The order is
the point — `check` answers `Known` on any same-algorithm match, so appending
without dropping leaves the key this one replaces trusted for good, which is
how a host that rotated away from a compromised key goes on accepting the old
one. There was a test named after that bug, but it called the two halves
itself, so removing the `forget_superseded` call from `apply_decision` changed
nothing it could see.

The sequence is now one function, `record_trusted`, with the order and the
"if the drop fails, append nothing" rule stated where it happens. Production
and that test call the same thing, so neither can drop a half alone. Verified
by re-running both mutations against the new tests; each fails, naming what
went wrong.
2026-08-23 04:25:53 +08:00
l0ng-ai 69ac479c97 test(kitty): prove the temp-dir check is what saves a file, not the OS
`a_temp_file_transfer_outside_the_temp_dir_is_read_but_not_deleted` sent a
`t=t` transfer at `/etc/hosts` and asserted it survived. It survives whether
or not the check exists: the suite does not run as root, so `remove_file`
fails on its own and `let _ =` swallows that. Removing the temp-dir check
outright left the test green — and that check is the one standing between an
escape sequence and `remove_file` on any path the user can delete, which the
comment beside it names as `~/.ssh/id_ed25519`.

The `/etc/hosts` half stays: it is the case that is never under a temp
directory on any host we build for, and it proves reading still works. Added
beside it is a victim the test user demonstrably *can* delete — created under
`CARGO_MANIFEST_DIR`, with a probe file removed right next to it first,
because a survivor proves nothing unless deletion was possible. If the
checkout itself lives under a temp directory the second half returns early
rather than asserting the opposite of what it means.

Verified: dropping the temp-dir check now fails it, naming what was unlinked.
2026-08-23 04:18:04 +08:00
l0ng-ai bf68eb9f20 fix(kitty): open a file transfer non-blocking, so the fifo check can run
`read_file` refuses anything that is not a regular file, and the comment
above it says why: fifos and devices, "where the open itself can block". The
check sat one call after the open. `open(O_RDONLY)` on a fifo blocks until a
writer arrives — for a fifo nobody intends to write to, forever — so the
guard never got the chance to refuse the thing it was written to refuse.

Reachable the same way everything else in this path is: the name comes out of
an escape sequence, so `mkfifo` plus one line of output, or any file someone
`cat`s, is enough. `resolve` runs on the daemon's pane reader thread, so the
pane's output stops there and does not come back while the process lives.

The open now carries `O_NONBLOCK`, which returns immediately on a fifo and
lets the existing check refuse it. On a regular file the flag changes
nothing — it is not consulted by `read`, and reads of regular files do not
block regardless.

Found by mutation: removing this check, the shm-name check, and the temp-dir
unlink check each left the whole workspace suite green, so all three were
being asserted only as pure functions and never where they are wired in.
Testing the first one properly is what surfaced the ordering.

The guard resolves a transfer naming a real fifo on a worker with a ten-second
deadline, because without the fix this does not fail, it hangs — and a test
that hangs says much less than one that reports what happened. Verified: with
the blocking open restored it fails at ten seconds with that message.
2026-08-23 04:15:43 +08:00
l0ng-ai ba28a8224e test(kitty): hold the remote transfer gate for t=f and t=t, not only t=s
The gate that stops a remote pane resolving a file or shm transfer had one
test and it sent `t=s`. Narrowing that gate to shm alone — so `t=f` and `t=t`
resolved on a remote pane — left the entire workspace suite green. Checked,
not assumed: 43 kitty tests and every other test in the repo passed with the
guard weakened.

That is the gate that matters. `query_reply` is the polite half; it tells a
well-behaved sender not to bother, and its own test already probes all three
media with a comment saying why. This is the half that holds when the sender
ignores the refusal, and the sender on a remote pane is the far end of an ssh
link naming a path on *this* machine. `read_file` will open whatever it is
given — bounded and regular-files-only, but given.

Now all three media are sent at a remote parser and refused, and all three at
a local parser and accepted, so the guard is pinned to refusing on locality
rather than on the medium. The failure names the path that would have been
read. Checked against both directions: narrowing the gate to shm, and
refusing regardless of locality.

No behaviour change — the guard was already right. This is the test that
would have noticed if it stopped being.
2026-08-23 04:09:48 +08:00
l0ng-ai 7368872e04 docs(protocol): state at PROTOCOL_VERSION when it moves
`CONTROL_VERSION` carries a long comment on what it is and what happened the
time it did not move. `PROTOCOL_VERSION` had none at all, and the rule for it
is not the obvious one: adding a `#[serde(default)]` field deliberately does
*not* bump it, because this number gates the remote handshake and bumping
would turn away every older `tty7-server` over a field it could safely
ignore.

That reasoning existed, twice, but only as asides — on
`AuthPromptKind::KeyPassphrase::rejected` four hundred lines down, and again
in the test that pins both directions of it. Someone changing a message reads
the constant, not the field that once raised the question.

So it is said where the decision gets made: additive-with-default does not
move it, removing or renaming or retyping a field does, and a new variant
depends on whether the sender can know the peer is new — if it cannot, bump.
No behaviour change.
2026-08-23 03:58:05 +08:00
l0ng-ai 9ca4b27ec9 test(control): pin the wire shape to CONTROL_VERSION
The comment on `CONTROL_VERSION` already records what happens when the
dialect and its number come apart. Between v5 and v6 the machine tree
replaced four verbs, `GitStream` arrived with its chunk and end events, and
two replies went away — "all of it shipped against a number that never moved,
so every one of those servers still answers the hello and then drops the link
on the first call." Nothing prevented that, and nothing prevented a repeat.

Every message this file puts on the wire is now fingerprinted and pinned
beside the version it belongs to. Change a shape and the test fails, which is
the moment to decide whether a peer built against the current version can
still read it — and to bump the number when it cannot, rather than finding
out from a user whose server answers the hello and then goes silent.

Comments and formatting are stripped so wording and rustfmt do not trip it;
`#[serde(...)]` lines are kept, because renaming a field or giving it a
default is exactly the kind of change the compiler cannot see and a peer
cannot survive. `ControlClient` is excluded — it holds the connection, not a
message. The scan asserts it found the ten types before comparing, so a
change in how the file is written fails loudly instead of pinning nothing.
The failure prints the value to paste back, and moving `CONTROL_VERSION`
alone fails too, so the pair cannot drift in either direction.

Checked against both: bumping the version without re-pinning, and adding a
`#[serde(rename)]` to a field — the second being the case that compiles
cleanly and would otherwise have shipped.
2026-08-23 03:55:32 +08:00
l0ng-ai 9332f2e44b test(shell-integration): hold every shell to the same reported facts
Five shells ship an integration script and only PowerShell had a test that
its OSC 133 marks were all there. The other four were covered piecemeal —
zsh's ZDOTDIR dance, bash's rcfile chain, the WSL bootstrap — none of which
would notice a mark going missing.

Missing is the bad case precisely because it is silent. `133;A`/`B` bracket
the prompt, `C` says a command started, `D;<code>` says it finished and how;
`7;file://` is the working directory. Drop one and `tty7 wait --until free`
stops waking, the tab strip stops showing exit codes, the running dot stops
lighting — for that shell only, with nothing logged, because a mark that was
never sent leaves no trace to find.

The floor is now asserted for all five. `D` is checked with its separator,
since a bare `133;D` means "finished, code unknown" — a different answer from
the one all five scripts actually give. Deliberately not asserted: an OSC 0/2
title, which only bash and PowerShell send; on zsh, fish and nushell the
user's own prompt owns the title and tty7 does not take it from them.

Verified by making fish send a bare `133;D`, which it names.
2026-08-23 03:44:59 +08:00
l0ng-ai 677a1dffe1 test(git-log): drive the lane allocator with random DAGs
`assert_lanes_line_up` already states the property the commit graph rests on
— a lane carries at most one visible line at any horizontal cut, and what
leaves a row's bottom is exactly what enters the next row's top — but only
hand-written shapes were ever fed through it. Those cover the cases somebody
thought of.

Two generators now do the rest. The first lays out 4,000 topo-ordered DAGs
(up to forty commits, up to four parents so octopus merges appear, roots
appearing mid-page) and checks the standing invariant plus three the layout
implies: one row per commit, a root sends no `Out`, and a `Pass` never
crosses the row's own node lane.

The second is about the thing the design is built around. `LaneAlloc` is
append-only so a later page extends the graph without touching a row already
on screen; 1,500 DAGs are laid out whole and again split at every position,
and the two must be identical. That property is invisible to a
single-page test and is exactly what a page boundary would break.

Nothing was wrong — both pass as they stand. Checked against two injected
regressions: not releasing a lane its commit owned fails the first, and
dropping the pending map between pages fails the second, naming the seed and
the cut. Together they run in half a second.
2026-08-23 03:39:04 +08:00
l0ng-ai e80fc24ce0 test(protocol): round-trip every wire message, and keep it that way
`client_roundtrip` and `daemon_roundtrip` are hand-written lists, so a
variant added later goes untested unless somebody remembers it — and nothing
said otherwise. Eleven had drifted out of the two lists; five of those
(`Handoff`, `QueryProcs`, `Image`, `DeleteImage`, `Procs`) were not encoded
or decoded by *any* test in the module. `Image` is the one carrying arbitrary
binary through a length-prefixed frame, which is exactly where a framing bug
would hide.

All eleven are added and all of them round-trip; nothing was broken. `Image`
gets 70,000 bytes cycling through every value, so it crosses the read
buffer, plus an empty one; `DeleteImage` gets a payload containing the APC
bytes a naive framer might resynchronise on; `Procs` gets both a populated
`PaneProcs` and an empty one; `AuthResponse` and `SshStatus` get each of
their own shapes.

The guard reads this file's own text: the variants `pub enum ClientMsg` and
`pub enum DaemonMsg` declare, against the ones the matching test names. One
list instead of two, for the usual reason. It asserts it parsed more than
twenty variants first, so a change in how the file is written fails loudly
rather than passing for the wrong reason.

Note what this does *not* need to catch: a brand-new variant does not
compile until `encode` and `read` handle it, so the compiler has that half.
What it catches is the half the compiler cannot — a variant that is wired up
correctly and simply never tested. Verified by dropping `QueryProcs` back out
of the list, which it names.
2026-08-23 03:31:12 +08:00
l0ng-ai f8b000ed9d fix(pane): clear the agent session when one agent hands over to another
`apply_agent` cleared `agent_session` only on the way to `None`, on the
assumption that a pane always passes through its bare shell between agents.
It does not have to. The foreground probe runs every 500ms and reads the
process-group leader; `claude; codex` starts the second agent microseconds
after the first exits, so two consecutive probes see `Some(Claude)` then
`Some(Codex)` and the agent-less moment in between is never sampled.

Everything the first agent reported then carried across. Codex arrived
already `Done`, wearing Claude's closing message, holding Claude's
`session_id` and the argv Claude was launched with. The tab drew a finished
turn Codex had not taken, `tty7 agents` agreed, and Resume built
`codex --resume <a uuid Codex has never issued>`.

A change from one agent to another now clears it, alongside the change to no
agent. Deliberately not on `None` -> `Some`: an agent's own hooks reach the
pane before the process probe does — `apply_agent_signals` creates the
session and sets `st.agent` from the event — so the probe catching up is not
a change of hands, and clearing there would throw away the arriving agent's
own state. The guard covers all three transitions and was checked against the
old condition, which fails it with Claude's session id printed inside Codex's.
2026-08-23 03:24:56 +08:00
l0ng-ai 5d3990dc3e test(agent-hooks): pin every hook event name to what the daemon reads
Twelve agents' hooks are rendered by this module and read by
`parse_agent_event` in another crate, and nothing checked that the two agree
on the names. `run_agent_hook` does not validate the event it was handed — it
puts the string straight into the OSC payload — and the far end drops an
event whose name is not an `AgentEventKind`. So a typo in a hook template, or
a rename of one variant, turns that agent's status reporting off entirely:
the dot never lights, `tty7 wait` never wakes, `tty7 agents` shows nothing,
and there is no log line anywhere because neither side thinks it failed.

The guard installs all twelve into a scratch home and reads the artifacts
back, so it follows whatever route a name actually took rather than trusting
a table the renderer might not use. Two shapes, because the agents split into
two kinds: nine write the event on a command line (`… agent-hook claude
tool-complete`), and the three plugin bridges — opencode, pi, omp — build the
command at run time and name the event in an `emit("…")` call instead. Both
are collected and both are checked. Comment lines are skipped: every bridge
spells the command out in its header, in three different comment syntaxes.

Verified against injected typos in both shapes — one in `CLAUDE_HOOK_EVENTS`,
one in a bridge's `emit` — and each is named in the failure with the agent
that would have gone quiet. As it stands all thirty-odd names parse.
2026-08-23 03:20:05 +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 209ebe6ca5 fix(gitignore): obey core.ignorecase, as git does
Differential-tested the ignore chain against `git check-ignore` on ~50
patterns — anchored, directory-only, `**` at either end and in the middle,
whitelists under ordinary and excluded directories, character classes,
escaped spaces, comments, CRLF, the `*` / `!*/` / `!*.c` recipe — and it
agreed with git everywhere except case.

`git init` probes the filesystem and sets `core.ignorecase = true` on a
case-insensitive one, which is every stock macOS and Windows install, and
git's ignore matching then folds case. The chain matched case-sensitively no
matter what. So a `.gitignore` whose pattern differs in case from the name on
disk diverged: `Build/` against a `build/`, `*.LOG` against an `a.log`. git
calls those ignored. The tree drew them as tracked, and — worse than a
styling difference — expanded and watched a directory git never descends.
The capitalised build directory is not a corner case; the .gitignore
templates and the tools that create the directory routinely disagree about
it.

Read from `core.ignorecase` rather than probed, because config is what git
obeys and someone who set it false on a case-insensitive disk means it. Once
per root, kept across `clear()`: editing a `.gitignore` cannot change the
setting, and a git spawn per keystroke in the ignore file would buy nothing.
The matcher cache is now keyed by the fold flag too, since a repository
nested inside another can answer differently.

The guard sets `core.ignorecase` explicitly in both directions rather than
leaving it to the probe, so it asserts the same thing on a case-sensitive
disk; its answers are `git check-ignore`'s under each setting. Checked
against both injected regressions — never folding and always folding.
2026-08-23 02:53:02 +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 21ddd01bc9 fix(updater): record a panic where someone can find it
`crash::install` puts a panic hook in front of `crash.log`. The GUI
installs it and the server installs it. The updater did not — and of the
three it is the one that needs it most.

It runs *detached*, after the GUI it is replacing has exited, so its
stderr is attached to nothing anybody will read. And it is doing the one
job in this product that can leave an install broken. A panic mid-swap
was therefore silence: the app does not come back, `tty7-updater.log`
stops mid-sentence, and there is nothing anywhere that says why.

All three of its `cfg`'d mains install it now, so the role travels with
whichever platform failed. `crash.log` is the file the other two roles
already write, in the config directory, so the three land in one place in
the order they failed.

The CLI stays out deliberately: it is a short-lived foreground process
whose panic prints to a terminal someone is already looking at, and a
second copy in `crash.log` buys nothing.

The guard reads the three entry points rather than a list kept here, and
was checked against the state that shipped — it names the updater.

The mechanism itself was already covered: `a_panic_lands_in_the_crash_log`
proves the hook writes the record. What nothing held was whether each
binary calls it, which is exactly what was missing.
2026-08-23 02:23:23 +08:00
l0ng-ai ebfc08368b fix(i18n): a number in a search keyword list must survive translation
Searching Settings for `16` found ANSI colours in English and Japanese
and nothing at all in Chinese. Both other languages carry "16" in that
row's keyword list; the Chinese one did not, and neither does the Chinese
title ("ANSI 颜色"), so the row was unreachable by the number people
actually type for it.

Keywords are match data rather than prose. A digit is the part of them
that does not translate — "16" is "16" in every language — so dropping
one silently narrows what a speaker of that language can find.

The guard holds every keyword list to carrying each number English does.
A translation may add its own; it may not lose one. Only keyword lists:
digits in ordinary copy are phrasing, and the Japanese for "one per line"
carries a 1 the English has no reason to.

Found by comparing numeric literals across the three tables, which is a
check nothing else does — the existing i18n tests hold placeholders,
plural branches and vocabulary, all of which this passes.
2026-08-23 02:10:48 +08:00
l0ng-ai 43d59b7317 ci(host-boundary): .exists() asks this machine too
The guard forbids the GUI four ways of reaching the filesystem directly,
because a path held by `ui::` or `terminal::` may name a file on a remote
workspace's machine. `.canonicalize()` is one of them. `.exists()` was
not, and it is the same question asked a shorter way: it answers about
the client's disk whatever machine the path belongs to.

`search::local_probe` is what the answer is supposed to look like — a
local prober and a remote one behind one `Probe`, with an `Unknown` that
means "nobody has asked yet". A bare `.exists()` is that split skipped.

Nothing was violating it. All four call sites are local by construction —
a destination the user picked in this app's own copy flow, a name it is
choosing for a download, and the themes directory — so they join the
allowlist with the reason, which is what the allowlist is for.

`.is_file()` and `.is_dir()` are the same hazard and are deliberately not
added. Between them they occur eleven more times, all on paths that are
local by construction, and the script's own header makes the argument
against: an allowlist that grows faster than the net it casts is one
people stop reading. `.exists()` earns its four lines by being the direct
analogue of a pattern already there.

Checked by injecting a `.exists()` on a pane-supplied path: the guard
names the file and line and exits 1.
2026-08-23 01:54:53 +08:00
l0ng-ai d60949437c docs(readme): nushell is an integrated shell, so say so
`shell_integration.rs` ships five scripts — zsh, bash, fish, nushell,
PowerShell. Both READMEs listed four of them and stopped before nushell.

The shell-integration reference page has described it all along, down to
why its wrapper works the way it does ("`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"). Only the front pages were missing it,
and the front page is where someone choosing a terminal looks — a shipped
integration nobody knows about is a feature that may as well not exist.

Both languages, because the Chinese README carries a translation of the
same table and had drifted the same way.

The guard holds every script in that file to being named in both, and
checks the scripts are non-empty first so the list cannot go stale by
quietly losing one. Checked against the page as it was: it reports
nushell missing from README.md.
2026-08-23 01:25:51 +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 2d5621a71c feat(palette): amend, refresh and the graph toggle are Git verbs too
The palette's Git group held commit, stage, unstage, discard, sync, push,
pull, fetch and the branch verbs. `ScmCommitAmend`, `ScmRefresh` and
`ScmToggleGraph` were bindable and unreachable from it.

A group that is nine tenths complete is worse than one that is obviously
partial: the three missing did not read as "not in the palette", they read
as features that do not exist. The command-palette page says every action
is in there "whether or not it has a keybinding", which for this group was
very nearly true.

All three go through the path their neighbours already use —
`run_scm_action` with an `ScmIntent` that existed, and `scm_toggle_graph`
for the view half — so nothing new happens, it is the same dispatch
reached a second way. Amend is no more exposed than what was already
there; `ScmDiscardAll` has been a palette command all along. The labels
are the ones the Keybindings page already shows for these actions, so the
two surfaces name them identically.

The guard is scoped to Git deliberately. Plenty of actions belong nowhere
near a palette — `HideApp`, `InsertNewline`, the numbered tab and
workspace families — so the whole-app version of that claim is prose, not
a contract. One coherent group either is complete or is not, and this
holds every bindable `Scm*` action to being reachable. It matches through
`key_spec` rather than by variant name, so a command spelled differently
from its action — `OpenBranchPicker` for `ScmCheckoutBranch` — still
counts.

`the_git_group_is_its_own_section` counted ten and now counts thirteen.
That number is a forcing function, not a fact worth knowing: adding a Git
verb should be deliberate, and bumping it is how that is confirmed.
2026-08-23 01:14:09 +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 b6b3e782b4 fix(doctor): name a custom shell that can never appear
A `custom_shells` entry with nothing to launch is dropped by
`append_custom`, which says so with `log::warn!` — and there is no log
unless `TTY7_LOG` is set.

The way an entry ends up empty is what makes this worth its own row.
`CustomShell` is `#[serde(default)]`, so a misspelled key *inside* an
entry — `programm` for `program` — is not a parse error but an entry with
every field defaulted. And `custom_shells` is itself a real setting, so
the unknown-key row added alongside this cannot see it: that one compares
top-level keys, and the typo is a level down.

So the file parses, `doctor` says `ok`, both key checks pass, and the
menu row simply never appears. Measured on a config holding one typo'd
entry and one good one.

    custom shells    1 of them name no program, so their menu rows never
                     appear: entry 0 — a misspelled key inside an entry
                     reads as an empty one

By position, because an entry broken this way usually has no label to
name it by — that is the same misspelling, one field over.

The test pins the mechanism rather than only the outcome: it deserializes
a misspelled entry and asserts it lands as `CustomShell::default()`,
because "an unknown key is not an error here" is the whole reason the
report is needed.
2026-08-23 00:42:13 +08:00
l0ng-ai 1552abb010 fix(doctor): name the config keys tty7 does not read
Mistyping a setting name is the likeliest thing to go wrong in a
hand-edited `config.json`, and the quietest. The file still parses, so

    config           ok

while the setting does nothing. `note_unknown_keys` has found these all
along — it says so with `log::warn!` behind a `log_enabled!` guard, and
per `docs/reference/privacy.mdx` there is no log at all unless `TTY7_LOG`
is set. Measured on a config carrying `font_siz`, `scrollback_limitt` and
one real key: nothing anywhere named the two that did nothing.

Unlike the keybindings map, this one `doctor` can ask: `unknown_keys` is
already in the crate the CLI shares, and already guarded by a test that
no real field may ever be reported as a typo. It only needed a way in.

    config keys      not settings tty7 reads, so they do nothing:
                     font_siz, scrollback_limitt — check the spelling
                     against the reference page

Only when the config parsed, and only when there is something to say. A
quarantined config is running on defaults and *every* key in it is
unread; naming them all would bury the row that matters. A clean config
prints no row at all, so this reads as news.

The test asks the pure function rather than the file-reading wrapper.
`unknown_config_keys` reads `TTY7_CONFIG_DIR`, and setting that from a
test steers every other test in the process — the first draft did, and
failed three runs out of three under the parallel suite.
2026-08-23 00:35:46 +08:00
l0ng-ai fa4a7e7e38 fix(keymap): say when a keybinding in config.json did nothing
Two places throw away an entry in `config.json`'s `keybindings` map:
`set_binding` when no action answers to the name, and `action_bindings`
when the chord will not parse. Both say so with `log::warn!`, and per
`docs/reference/privacy.mdx` there is no log at all unless `TTY7_LOG` or
`RUST_LOG` is set.

So on a default install a hand-edited typo costs the user their shortcut
in silence. The file still parses, which means `tty7 doctor` reports

    config           ok

while two of the three bindings in it do nothing. The only evidence left
is a key that never fires, and nothing connects that to the line that
needs fixing — measured on a config carrying one typo'd action name, one
unparseable chord, and one good binding.

The window says it now, the same way it already says a config.json did
not parse at all, and for the same reason: the symptom on its own reads
as "tty7 ignored my settings".

It is answered in the window rather than in `doctor` because `doctor`
cannot ask. The action table and the keystroke parser both live up here;
the CLI shares only the crate underneath, and moving either down to
answer one diagnostic would be the tail wagging the dog.

Two things are deliberately not faults. An empty chord is how a binding
is *unbound* — `action_bindings` skips it on purpose. And a quarantined
config is running on defaults, so its `keybindings` were never read;
complaining about them would name a map nothing consulted.

The collector is tested against all four cases, and checked against the
log the two drop-sites emit: it names exactly the pair they drop and not
the binding that works.
2026-08-23 00:29:10 +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 ec7a7a2fa5 fix(ssh): a forward with no bind address binds loopback, not the resolver's guess
Three forms build a port forward: the settings sheet, the side panel, and
a `LocalForward` line read out of `~/.ssh/config`. Two of them turned a
blank bind address into `127.0.0.1`. The settings sheet passed the empty
string through to the bind.

Where that lands is not ours to decide once it leaves: `""` is whatever
getaddrinfo makes of it. On macOS and glibc today that is loopback, which
is why nothing looked wrong — but it is the resolver's answer, not the
app's, and under `AI_PASSIVE` semantics the same string means every
interface. An SSH tunnel reachable from the network is not a state to
arrive at through a default nobody chose, and the sheet's own field
already shows "localhost" as its placeholder, so a user leaving it blank
has been told what they are getting.

The side panel's `collect` claimed, in its doc, to apply "the same
conditions the settings sheet's `ForwardRuleForm::collect` applies". It
did not, and that is the sort of comment that stops anyone checking. Both
now call one function beside `HostPort`, along with the ssh_config
parser, which had the third copy of the same literal.

Tested from both ends: the rule the settings sheet builds from a blank
field, and the rule the side panel builds from the same blank, are
asserted equal — and against the unfixed sheet the first half fails.
2026-08-22 23:15:52 +08:00
l0ng-ai b23e0feb55 fix(editor): closing a tab no longer throws away unsaved edits
The code panel hangs off the tab, so closing the tab drops every buffer
in it. `editor_close_file` asks before closing a *file*, and the file
tree marks a dirty one — but nothing outside `code_editor` read `dirty`
at all, and `tab_close_reason` looked at `pane.terminals()` and nothing
else. Every close path that went through the tab took the edits without
a word.

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

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

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

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

Both loss paths were run against the unfixed code and fail there. The
third test — a saved buffer still closing outright — passes either way on
purpose: it holds the guard to the edits rather than to the mere presence
of a code panel.
2026-08-22 23:06:58 +08:00
l0ng-ai 62d44f3d0f fix(doctor): say when a workspace tree was set aside
A `machine.json` that does not parse is copied to `machine.json.corrupt`
and the machine comes up with no workspaces at all — every tab and every
pane layout on it. `MachineStore::open` calls that "recoverable by hand",
and it is, but only for a hand that knows where to look.

Nothing told it. The quarantine announces itself with a `log::warn!`, and
per `docs/reference/privacy.mdx` there is no log at all unless `TTY7_LOG`
or `RUST_LOG` is set — so on a default install the whole thing is silent.
What the user sees is `tty7 ws ls` saying "no workspaces — `tty7 new
<path>` starts one", which reads as an empty machine rather than as a
lost one, and gives no reason to look in the config directory.

`doctor` already makes this argument for `config.json`: a file that does
not parse is exactly the state someone runs `doctor` in, and none of it
is visible from the rows around it. The tree is the same case with more
at stake — settings are still on screen when `config.json` is quarantined;
workspaces are not.

The row only appears when a copy is really there. One that said "no tree
was set aside" beside every intact machine would be noise on every
install, and this has to read as news.

Verified end to end against a running server rather than only in a test:
corrupt the tree, restart, `doctor` names the copy; restore it, the
workspaces come back and the row goes away.
2026-08-22 22:56:55 +08:00
l0ng-ai c27d88e001 fix(tab-strip): an ellipsis that stands for nothing dropped
`short_title` counted `~` toward a path's depth, so a home path was cut
one segment earlier than an absolute one of the same shape:
`/usr/local/bin` kept its root, `~/repo/025/tty7` became
`…/repo/025/tty7`.

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

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

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

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

Verified against codesign rather than reasoned about:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The guard reads the names out of `key_spec`'s own source, because the
match *is* the list and a second copy here would drift the way the first
one did. Both it and the settings-index guard were checked against an
injected regression rather than only against a green tree — a phantom
action and a removed index entry each fail them.
2026-08-22 21:46:06 +08:00