hide, move to 9;9, show -> cursor at 9;9
hide, move to 9;9, CSI g, show -> cursor back at 6;4
The scanner sorts CSI finals into ones that move the cursor and ones that
leave it alone, and treats everything else as a paint. A paint clears
`last_was_move`, which makes the following show "parked" -- the cursor is
put back on the cell it was hidden on, because a frame that ended on an
erase left it wherever the erase finished.
Terminal settings are on neither list and were being counted as paints.
Clearing a tab stop, or setting an ANSI mode, between a frame's last move
and its show therefore dragged the cursor off the cell the frame had
chosen. Demonstrated above before changing anything, with `CSI 4 l` doing
the same.
Which finals belong on the list came from `vte`'s own dispatch rather than
from the spec: `g` clears tab stops, `h`/`l` set and unset ANSI modes --
their `?` forms return earlier, so this cannot disturb `?25h`/`?25l` -- and
`k` is SCP. All four reach the emulator without touching the grid.
`p` stays where it is, which is worth writing down because it looks wrong:
`CSI ! p` is DECSTR and homes the cursor in the spec, but `vte` implements
only the `$p` and `?$p` forms, so DECSTR never reaches the grid here.
Classifying it as a move would describe a terminal this is not.
The test holds all four finals against the position the frame chose, and
keeps the erase case parking, which is what the pairing is for.
Three sites answered "frame payload exceeds MAX_FRAME". `MAX_FRAME` is a
name only this side of the wire knows, so a peer -- or anyone reading a
log after a frame was refused -- learns neither how big the frame was nor
how big one may be. The control protocol's equivalent already reads
"control frame of N bytes exceeds the M-byte limit"; the pane protocol
had been left behind.
Swept for the shape after the last commit, where a message printed a cap
where a count belonged. Everything else in this class is already right:
`local.rs` and `control.rs` name the actual size beside the limit, and
`server.rs` names the frame it refused.
The read path was already covered against a hostile length -- `frame_edges`
sends `u32::MAX` and checks the refusal comes before anything that large
is allocated -- so this adds one assertion there rather than a test of its
own: that the refusal carries both numbers. Reverting the wording fails it.
$ tty7 new /tmp
tty7: this machine already holds 1024 workspaces
against a machine.json holding 5001. The message printed the cap where a
count belongs. The two are the same number the moment the limit is first
reached, which is why it read plausibly for years; a tree that arrived
over the line -- a hand-edited file, or a build that allowed more -- gets
told a number it cannot reconcile with the rows `tty7 ls` puts in front
of it.
Both limits said it, so both now name what the tree holds alongside the
limit, and what to do about it.
Nothing else about an oversized tree needed changing, which is worth
saying: 5001 workspaces load whole and `tty7 ls` answers in 0.08s. The cap
is enforced when something is created, not when the file is read, and
that is the right way round -- refusing to load would strand every row
past the thousandth rather than let the reader close some.
The test duplicates what the store itself wrote rather than hand-writing a
fixture, so it cannot drift from the schema, and it puts the tree seven
over the cap so the count and the limit are different numbers. The old
wording fails it.
Deleting `cfg.sanitize()` from the load path left the whole suite green.
`sanitize_clamps_degenerate_font_metrics` holds what the function does;
nothing held that the loader calls it.
The file is the one place these values arrive unchecked -- the settings
page has its own bounds -- so a zero font size, or a NaN, or the
`ui_font_size` whose comment warns that the whole chrome is a multiple of
it, reaches the layout from here or from nowhere. The test writes such a
file, loads it, and checks what comes back; deleting the call fails it on
the zero.
Two neighbours in the same sweep are already held: the quarantine that
parks a corrupt file before defaults are handed back is caught by three
tests, and the startup stale-endpoint removal by two.
One gap is left open deliberately. Removing `note_unknown_keys` from the
same function is caught by nothing, but its only effect is a warning --
the typo'd key falls back to its default either way -- and catching it
needs a global logger installed from a test, which in a suite this
parallel is a worse thing to own than the gap.
Deleting `history::carry` from the pane-create path left the suite green,
and what it costs is what the function's own comment names: the commands
the predecessor ran, which are the ones a reader reaches for first after a
restore. `carry` and `restored_screen` are each held by their own tests;
nothing held that a pane replacing a dead one calls either.
Rather than another source-reading guard, the two statements move into
`take_over_from`. They belong together anyway -- they are one decision,
that this pane continues that one, and a caller doing half of it hands
back the scrollback with an empty Up key, or the reverse. Both have to run
before the spawn, because the spawn is what hands the shell the name of
its history file.
`handle_conn` cannot be entered by a test; this can, and the test holds
both halves plus the no-predecessor case. Each half was deleted in turn to
check it fails.
Two neighbours came out of the same sweep needing nothing: the
stale-endpoint removal on startup is held by two tests, and the one in
`on_shutdown` is belt and braces for it -- a socket left by an unclean
exit is what the guarded one already handles.
Same shape as the last commit, found the same way -- delete a call and
count what notices. Both were zero.
`restored_screen` drops the snapshot it hands out, which the privacy page
states and the function's own comment explains: a copy left behind could
only ever put the same screen into a second pane. The test covers both
branches, because that comment is careful about the difference -- an empty
snapshot is not a screen worth restoring, but its file still goes, and
only the return value turns on emptiness.
The startup sweep is the one that cost something already: without it,
4,457 shell-integration directories piled up in TMPDIR, and the three
tests beside the new one hold what the sweep does rather than that anyone
runs it. Its only caller binds a listener and then serves forever, which
no unit test can enter and which the e2e harness starts before a test body
can plant anything. So that guard reads the source, and its comment says
what that is worth: it proves the call is written, not that it is reached.
A sweep moved somewhere unreachable would still pass. It catches deletion,
which is how it broke.
Deleting `scrollback::forget(pane_id)` from `kill_pane` left the whole
suite green. The invariant is written down twice -- the privacy page says
the file goes "at once", and `kill_pane`'s own comment says why, that a
screen left behind is one that can turn up in some later restore -- and
nothing held it. What looked like coverage tested `forget` itself, which
keeps working while nobody calls it.
The test drops a screen for a pane id that is not registered, because
that is the case the call has to survive: `kill_pane` must forget the
screen whether or not it found a pane to stop.
Found by mutation-testing the invariants that would be worst to break,
rather than by reading. The neighbours came out well and are worth
recording: removing `:(literal)` from a pathspec fails eight tests
including one named for it, and making `checksums::verify` accept
anything fails four, one of them at integration level.
`fuzzy_score` adds 120 when the folded haystack equals the needle. Only a
single-word title can: the needle drops whitespace so `newtab` and
`new tab` both match, while the haystack keeps it, so `Settings` can equal
its query and `New Tab` never can.
Nothing is wrong with the ranking -- typing any of twenty overlapping
titles in full ranks that title first, `New Tab` over `New Worktree Tab`
and `Rename Tab` over `Reopen Closed Tab`. The word-start and run bonuses
carry it without the 120. But that is not visible from the branch, and
somebody tuning either bonus deserves to know what is holding the case up.
I wrote a test for the whole-list outcome first and threw it away: it
survived weakening the word-start bonus from 12 to 1, deleting the length
penalty, the run bonus, the prefix bonus and the exact bonus. Typing a
complete title matches from position 0 with nothing skipped, which
dominates under any scoring this file is likely to have, so the test
asserted something no plausible change breaks. The pairwise tests beside
it -- initials over scattered letters, exact over mid-string -- are the
ones with teeth.
$ tty7 send %1 "$(printf 'echo ONE\necho TWO')"
... echo ONE
ONE
... echo TWO <- typed, waiting
Correct for a verb whose first line is "types TEXT into the pane exactly
as a keyboard would" -- typing a newline is pressing Enter. But `--enter`
is documented right next to it as the way to submit, which reads as
though TEXT alone cannot, and an orchestrator passing along text it did
not write runs it a line at a time.
So `--help` and the reference now say it, with the shape it takes: the
text before the newline runs, and what follows is left typed at the
prompt it asked for. A test pins the bytes -- neither stripped nor split
into two writes -- so the pages and the behaviour cannot drift apart.
Found while feeding a pane input it does not expect. Three neighbours
came through that unchanged and are worth recording: invalid UTF-8 in
output becomes U+FFFD with correct recovery (`\xc3\x28` is one
replacement and a literal `(`), `capture --json` stays valid JSON over
it, and an OSC title carrying bad bytes is folded before storage so
`machine.json` stays parseable and the tab table stays aligned.
The hashed-host test builds its line with the same `hmac_sha1` it then
checks, so it holds whether or not that agrees with anyone else. The
digest is not the gap -- `sha1_matches_known_vectors` and
`hmac_sha1_matches_rfc2202_vector` pin it against outside vectors, and
breaking one SHA-1 constant fails all three tests, which is how I checked.
The gap is everything wrapped around the digest: base64 in the order this
file reads it, `|1|` splitting where OpenSSH puts the separator, and
`host_token` spelling a non-default port the way ssh-keygen does --
`[example.com]:2222`, brackets and all. Nothing pinned that, and getting
it wrong makes every hashed entry for a non-22 host read as a host never
seen before. Not a wrong trust, but a user with `HashKnownHosts` on would
meet the prompt at every connection.
So both prefixes here came out of a real `ssh-keygen -H` run and are
matched end to end. They pair with this file's own key because the digest
covers the host token alone.
Checked while reading the surrounding logic, which needed nothing: revoked
entries are consulted first and outrank everything, `@cert-authority` lines
are skipped where they would name the wrong key, a same-algorithm
mismatch outranks a different-algorithm one, and `!negation` short-circuits
the whole comma-separated list the way OpenSSH does.
Undo a line, press Backspace at column 0, and the line could not be
brought back. Confirmed before changing anything: redo restores "abc"
normally, and returns "" once a no-op Backspace sits between the undo and
the redo.
Every delete took its checkpoint before asking whether it had anything to
delete, and `checkpoint` clears the redo stack. So a keystroke that could
not change the line -- Backspace at column 0, Delete at the end, either
word-delete at its edge, either kill-to-edge already at that edge --
discarded the redo it had just earned. Six of them, all the same mistake.
Each now returns before the checkpoint when there is nothing to do. The
two predicates differ and are worth keeping apart: Backspace and Delete
eat a selection wherever the cursor sits, so they ask about one, while the
kill-to-edge family does not touch a selection and is decided by the
cursor alone.
The test walks all six through the same undo-then-no-op-then-redo
sequence, and a second one checks the guards do not block a delete that
does have work, including a selection deleted by Backspace at column 0 --
which is the case a guard written as `cursor == 0` alone would break.
`https://例え.jp` in a pane came back as the link `https://`. Clicking it
opens nowhere.
`is_url_char` is ASCII on purpose -- CJK runs together with whatever
follows, so accepting non-ASCII would let `https://example.com見て`
swallow the rest of the sentence -- and an internationalised domain
therefore truncates at its first character. What was left was the scheme,
and it was handed back as a link anyway.
The ASCII policy stays; only the hostless leftover is refused. A reader
with such a URL now gets no link rather than a broken one, which is the
honest answer, and the limitation is written down where the decision is
made rather than being an accident of `is_ascii_alphanumeric`.
Found by probing the edges rather than reading them. Seven other cases
were already right and are worth recording: a trailing full stop, wrapping
parens and angle brackets are stripped, while balanced parens inside a
path, a comma mid-path and a query string with `&` are kept.
`regex_escape` carried its own list of eighteen characters to backslash.
It is the right eighteen -- compared against `regex::escape` over every
ASCII character plus a handful of multibyte ones, zero differences -- but
right by having been copied.
What a literal search has to neutralise is whatever *this engine* treats
as meta, and the engine parses with `regex-syntax`, which is the crate
`regex::escape` calls. So the dependency is not merely equivalent here, it
is the definition; a copied list is correct only until that one grows, and
the failure would be silent: a reader searching for a literal `a{2}` would
quietly get a repetition.
`regex` is already a dependency of this binary (smart_select uses it), so
this drops twenty-five lines and adds nothing.
The test changed shape with it. Asserting `regex_escape("a.b*c")` spells
itself `a\.b\*c` is testing the dependency now; it searches a real grid
instead, where `a.b` finds `a.b` and not `axb`, and a reader's `f(x: u8)`
and `{ x + 1 }` find themselves. That is the behaviour the escaping is for,
and it fails without it -- checked, 2 matches where 1 is right.
With case sensitivity off -- the default -- typing `Error` into the search
bar found nothing on a screen holding `error` and `ERROR`.
`RegexSearch` applies smart case: it turns matching insensitive only when
the pattern carries no uppercase letter of its own. tty7 wrote `(?-i)` for
the sensitive setting and passed the query bare for the insensitive one,
so the engine's rule decided instead of the toggle, and the toggle looked
broken exactly when a reader typed a capital.
Confirmed against a real grid before touching anything:
pattern Error -> no match <- what the toggle-off path sent
pattern (?i)Error -> MATCH
pattern (?-i)Error -> no match
pattern error -> MATCH <- why it seemed to work
Smart case is a fine default for a search box with no toggle. This one has
a toggle, and it has to be the thing that decides, so both settings are
now written out. A pattern of the caller's own still wins -- `(?i)` is only
a default the rest of the expression may override, so `(?-i)Foo` in regex
mode means what it says.
The pattern builder became a free function so the test can drive it
without a view, and the test searches a real `Term` rather than asserting
on the pattern string: what matters is what the engine does with it, which
is the whole subject of the bug. It fails on the old code with 1 match
instead of 3.
`probe_status` asks `rev-parse` for the toplevel, the git dir and the
common dir in one call, deliberately: each is an RPC on a remote
workspace. But `rev-parse` has no `-z`, and git quotes none of what it
prints. Checked against real git, in a repository under
`/tmp/nl<newline>test-repo`:
$ git rev-parse --show-toplevel --git-dir --git-common-dir
/private/tmp/claude-501/nl
test-repo
.git
.git
Four lines for three values. Splitting on lines then took `/tmp/nl` as
the root and `test-repo` as the git dir, and everything downstream --
the panel, the diff, the file tree -- worked from a repository that does
not exist.
The batch is still used whenever it is unambiguous, which is every
ordinary repository and costs nothing. Anything but exactly three lines
is re-asked one flag at a time, where the answer is the whole output bar
its final newline and cannot be misread however many newlines are inside
it. Only one trailing newline is stripped, since a directory may end with
one.
Verified end to end against that repository: the root comes back whole.
The unit test carries the four-line shape rather than the fixture, so it
runs on a filesystem that will not hold such a name.
The rest of this module was already careful here -- `status` is read with
`--porcelain=v2 -z` and its tests cover a path with a newline in it. This
was the one call git gives no way to delimit.
Checked the page against the keymap in both directions and found no rot,
which is worth writing down as much as a fix would be:
- All 47 action names it prints exist, including the compound families
(`ForkAgentSession` + `Right`/`Left`/`Up`/`Down`).
- Every default chord is on the page. Eight looked missing and none were:
`ActivateTab2`..`8` live under the page's "Go to Tab 1-9 / ⌘1…⌘9", and
`IncreaseFontSize` reads `secondary-=` in the table while the page says
⌘+, which is right because line 41 binds `secondary-+` as well.
Only the names are pinned. The page lists bindings by label and chord --
"New Tab | ⌘ T" -- and a test that parsed those would be asserting against
a notation a person chose, failing on a range or a middle dot rather than
on anything being wrong. The names are identifiers, so they can be
compared to `default_bindings()` without interpreting anything.
Bare direction words are skipped rather than expanded for the same reason:
guessing that `ResizePaneLeft/Right` means `ResizePaneRight` is guessing at
prose, and the stem is checked either way.
Verified by renaming a page entry to `RenameTabNow`, which fails it.
The same defect as the previous commit, found by sweeping for it: a doc
block holding one item's summary followed by another's, with no blank
line between, so rustdoc attributes the whole thing to whichever item
happens to follow and the other is left undocumented.
daemon/pane.rs "The pane's screen, capped for storage, with the mark"
describes `scrollback_snapshot`, which returns exactly
that pair and had no documentation. It was sitting on
`scrollback_mark`, in front of that function's own.
ui/tree_sync.rs "This workspace's layout, and the name the machine has
for it" was on `settle_chosen_name`, which settles a
name and returns one. `git log -S` puts it above the
signature returning `(WsMirror, Option<String>)` --
`pull_or_create` -- so it was moved, not guessed.
core/ssh_config The "does this alias still resolve" paragraph, with the
four-times-a-second cost that explains the cache, was
on `struct AliasCache` rather than on
`alias_still_resolves`.
A fourth hit was not this: `file_tree_reveal_path` really does own both
of its paragraphs, and only wanted the blank `///` that separates them --
without it rustdoc runs "use file_tree_show when the tree *is* the
answer" straight into "returns whether the tree could hold it".
Nothing catches any of this. A fused block is well-formed rustdoc, so
`-D warnings` passes; the only symptom is a function with no docs and a
neighbour with somebody else's.
`reported_exit_code` had a twenty-line explanation of how portable-pty
stores a signalled child, why `exit_code()` reads 1 for both a SIGKILL and
an honest exit 1, and why sniffing `Display` is the only discrimination
the crate offers. d7e6b45 inserted `SigtermUnblocked` between the comment
and its function, and rustdoc does what the source says rather than what
it means: the essay became the first two thirds of `SigtermUnblocked`'s
documentation, and the function it was written for had none.
Nothing warns about this. `-D warnings` on rustdoc is happy -- a fused
doc comment is well-formed, it just documents the wrong item -- so it
survived every gate this branch runs.
Also drops a shorter comment and a test I added yesterday for the same
function. Both were written without noticing the originals: the essay says
everything my comment did and explains the crate internals besides, and
`signalled_status_is_not_reported_as_a_plain_exit` covers everything my
test did plus the trap itself (`killed.exit_code() == 1`) and the negative
case. Two descriptions of one rule is how they start disagreeing.
Found while chasing the inconsistency the essay itself already names: an
adopted pane gets `128 + n` from its own `waitpid` while this path reports
unknown. That is deliberate and reasoned there -- the name comes from
`strsignal`, which is localized, so mapping it back to a number is a guess
dressed as a fact -- and it is left alone.
Both found by running the verbs rather than reading them, and one of them
is mine from four commits ago.
`--json` said two verbs still print JSON when they fail. There are three:
`doctor` prints its whole report on the exit-1 path, deliberately, because
an unreachable server is the finding and `tty7 doctor || alert` needs the
rows as well as the code. My earlier batch simply had not included it --
this time every verb that can fail was run with --json, and the other
seventeen do print nothing.
The `run` section was worse. It said a signal death reports
`exit_code_known: true` "because a status was read", and concluded that
`run` therefore cannot tell a command the OOM killer took from one that
exited 1 on its own. The flag is false, and it is exactly that
distinction:
$ tty7 run --json -- sh -c 'kill -9 $$'
{"pane":14,"exit":1,"exit_code_known":false,"kept":false}
`reported_exit_code` returns `None` for a status that spells itself
"Terminated by", `run` turns that into an exit 1 plus a note on stderr,
and an orchestrator can branch on the flag without reading the pane. The
page had been telling it to do the opposite.
So the daemon side now carries a comment saying why a signal is not
dressed up as `128 + signal` -- that convention belongs to the shell, and
inventing it here hands back a number the command never returned -- and a
test pins `None` for a signal against `Some(code)` for an exit, including
the "Terminated by" spelling the whole thing turns on.
Two claims in yesterday's comment were worth checking, and one was wrong.
A path really is a live vector. I made a directory called `evil<ESC>[31mdir`,
opened a pane in it, and it lands in the CWD column of `pane ls` -- now as
`evil?[31mdir`, zero escape bytes. That is a hostile name arriving from the
filesystem rather than from a rename the reader typed, which is the case
worth defending.
A tab's OSC title is not, or not any more: the daemon already folds control
characters to spaces before storing one, because gpui paints a label's tail
over whatever sits below it when the title contains a newline. So titles
reach the CLI clean, and the comment overstated the case by naming them.
The sanitiser still covers them and deliberately does not lean on the
folding -- that exists for the GUI and could reasonably move -- but the
comment now says which is which.
Also stops a nearby test asserting `chars().count() == 40` on a clamp that
bounds columns. Its title is ASCII so the two agree and it passed either
way, but read on its own it states the contract the previous commit just
finished correcting.
`clamp` is there so "one talkative tab cannot widen every column in the
table" -- its own comment. It counted characters, so a CJK title went
through at two columns each:
@1 工作工作...工作… tty7 1 <- 40 chars, 79 columns
and `tab ls` came out 98 columns wide, past an 80-column terminal, doing
the exact thing the clamp exists to prevent. Every locale this app ships
a UI for is affected, and none of the ASCII tests could see it.
The rest of the file was already careful here -- `width()` exists, and its
comment says padding by `len()` would push later columns out of line --
so this was one function measuring in different units from its caller.
Now it fills a column budget, dropping a wide character whole when it
would straddle the end: half of one is not a narrower character, it is a
different one. `saturating_sub` on the ellipsis's own column also retires
an underflow that `clamp(s, 0)` would have panicked on, unreachable as it
was with a single caller passing 40.
Measured on a live server before and after: 98 columns down to 58, with
every row still starting its fields at the same place.
The previous commit sanitised the tables. Errors quote what was typed and
did not:
$ tty7 ws rm "$(printf 'X\033[31mY\033[0m')"
tty7: no workspace named 'X<ESC>[31mY<ESC>[0m' -- `tty7 ls` lists them
Measured across eight verbs with a hostile argument, every leak was on
stderr and none on stdout -- the tables cover the stdout side already, and
nothing else there embeds free-form input. So this is one boundary, not
one call per message: `main` sanitises whatever an error carries, and a
message written later is covered by having been written at all.
The sanitiser is the one from the tables rather than a second copy, which
is the mistake the daemon's two disconnect logs already made once.
stdout is deliberately left alone. `tty7 capture` prints a pane's stored
bytes with their escapes intact -- that is the point of the verb -- and
sanitising the report path would have quietly gutted it. Checked after
the change: 46 escape bytes still come back raw, `--plain` still returns
none, and the text is unchanged.
The e2e test runs the real binary, because the unit test covers the
sanitiser while only a spawned process covers the single call in `main`
that puts it on the error path -- the part an edit could drop with every
other test still green. It also asserts the name is still readable: a
message that hides which name was refused has traded one failure for
another.
$ tty7 ws rename <id> "$(printf 'evil\033[31mRED\033[0m')"
$ tty7 ls | xxd | grep 1b
... 65 76 69 6c 1b 5b 33 31 6d ...
The escape went straight through to the terminal. `tty7 ls`, `pane ls`,
`ws tree` and the rest print names the CLI did not choose: a workspace or
tab name, a path -- a directory can be named with an escape in it, which
is the old `ls` trick -- and, through `tab_label`, a tab's OSC title,
which is set by whatever program is running in the pane. With `-m` the
tree comes from another machine entirely.
It threw the columns out as well, which is how I found it. The table
measures in display columns and already had CJK right, but an escape is
bytes with no width, so the padding counted characters the reader never
sees and that row's later columns sat nine over.
Control characters become `?`, the way `ls` has always done it, at the
two places human output is built: every table cell, and the tree, which
formats its names directly. `--json` is untouched -- it has to round-trip
the real name, and an encoder already writes the escape as six safe
characters.
Bidi overrides are deliberately left: they reorder a name without the
terminal obeying anything, and the same codepoints carry ordinary
right-to-left text.
Verified on a live server (zero escape bytes, columns level again across
ASCII, CJK and the hostile name) and the test fails without the fix.
`UpdateInstallHint::english()` held a second copy of all nine install
hints, and three tests asserted against it: that a missing-package hint
names the package, that the checksums hint says tty7 "refuses" rather
than that it broke, that the all-users hint mentions the release page.
Nobody reads that copy. It is `allow(dead_code)` outside tests, and what
a user sees comes from `localized_update_install_hint` and the L10n
table. So the tests were guarding a private duplicate: rewording the real
string to "The update could not be installed." kept every one of them
green. Verified by doing exactly that — and the repointed tests fail on
it, which is the whole point of the change.
The tests now call `localized_update_install_hint`, and the copy is gone
with the impl block that existed only to hold it: nine duplicated
sentences that had no way of noticing they had drifted from the ones
being shipped.
The locale is set to "en" explicitly rather than relied upon. It is index
0 and the default, but `set_locale` in a test writes a thread-local that
outlives the test on that thread, so an English assertion should say it
depends on English.
Found by sweeping for prose duplicated across files; this was one of two
hits in the workspace, the other being a technical error string.
The all-users test is Windows-only and cannot be built here, so CI is
its first compile — it is the same two-line shape as the pair above it.
Both load failures logged "using defaults". That load is shared by
startup and by hot-reload, and only startup goes on to run them: a reload
that fails deliberately keeps the settings the app is already on, so the
UI does not flash onto defaults over a typo — `apply_reloaded_config`
says so, and the toast has separate startup and reload wordings for the
same reason.
So the log was the one place that told a reader their settings had reset
when nothing had. "Returning defaults for this load" is what actually
happened, and what the caller does with them stays the caller's to
report.
Found while probing hot-reload for a partial-write hazard, which is not
there: the watcher debounces 200ms, a broken file is copied aside rather
than moved, the original is left where the user put it, and the app keeps
running on its old settings. Breaking the file a second time raises a
second warning, which is the observable proof that the valid config in
between was picked up — the one-toast latch clears only on a load that
parses. An unreadable file parks no copy, so the toast never sends anyone
after a `.corrupt` that was never written.
The flag's row promised "one JSON object on stdout" and stopped there.
Running the error paths, most verbs print nothing on stdout at all —
`capture %999`, `procs %999`, `tab close @99`, `ws rm nosuch`,
`tab rename @99 x` each exit 1 with the message on stderr and an empty
stdout. A reader that parses stdout without checking the exit code first
gets an empty string where it expected an object.
Two verbs deliberately do the opposite, because there the bad news is the
answer: `pane close` prints `failed[]` and `wait` prints the state it
gave up in. Both are already pinned by tests, so this only writes down
the rule they are exceptions to.
`run` looked like a third exception and is not: a child exiting nonzero
is the verb succeeding. Checked rather than assumed —
`run --json -- sh -c 'exit 3'` prints its object and exits 3 — and the
row now says so, since that is the case most likely to be misread as a
failure that kept its JSON.
`tty7 wait %1 --until free --timeout 2` against a pane running `sleep 30`
answered:
pane %1: still no-agent — timed out
nothing is reporting agent status in this pane — for a plain command
wait `--until free`, and for an agent check `tty7 agents` ...
Advice to pass the flag that was just passed. A plain shell reports no
agent status, so a `--until free` wait that runs out lands on the
`no-agent` hint, which was written for someone waiting on agent states
and never checked whether `free` was already in the until-set.
Worse than useless, because it displaced the answer: `free` is polled
every cycle when asked for, so still being here means the foreground
command has not exited. That is now what it says, with `tty7 procs` to
find out what is holding the pane — which names `sleep` on the pane above.
The other branch is untouched: someone waiting on agent states in a
plain shell still needs pointing at `--until free`, and the test holds
both sides so neither hint drifts onto the other's case.
`--scrollback`'s help said the flag makes no difference "for a
never-resized pane", and the reference said the same. Both are wrong for
the panes where the difference matters most.
I killed a daemon under a running GUI, watched it come back, and captured
a pane that had `RESTORE-MARKER-7788` in it. Plain `capture` answered with
two lines — the restore banner and a prompt — while `--scrollback` had the
marker and everything around it. Nothing had been resized.
`ReplayRing::seeded` ends with `resize(size)`, deliberately: the restored
screen is replayed at the size it was recorded at, and the new shell
writes at the size the pane came back as. When those differ the restored
screen is sealed into an earlier segment, and the default capture cannot
see it.
That is the agent-facing primitive answering "almost nothing" for a pane
that kept its screen, with the help explaining that this only happens
after a resize. Both now say restores count, and say what a plain capture
looks like when it does.
`resize` returns early on an unchanged size, so a pane that comes back the
same shape really does keep one segment — the test covers both sides of
that, which is the part the wording turns on.
`tty7 tab new --help` printed:
Arguments:
[WORKSPACE]
and nothing else. Every flag in this CLI carries help — I checked that
in a previous pass — and every positional carried none. Twenty-two of
them, the main argument of each verb. The gap is invisible in the source,
where a `value_name` sits where documentation would go and looks like it.
`--help` is where a verb is learned, so the blanks were hiding the things
a reader most needs, and three of them are genuine surprises:
tab move INDEX counts from 0 while tabs are addressed from 1, so
`@3 0` makes a tab first. Past the end it lands last.
new PATH omitted, the shell starts where the *server* was
started, not where you are.
ws new NAME omitted, the workspace has no name at all — the
codenames come from the GUI, not from here.
Each of those was established by running the commands against a live
server and reading the result, not by reading the code: I had guessed
the codename one the other way round.
The guard walks clap's own tree, so it covers a verb added later without
being told about it, and it refuses to pass on a walk that visits almost
nothing — a sweep that reads nothing looks exactly like one that finds
nothing wrong.
"What is stored, and where" listed settings, keychain entries, scrollback
and shell history, and omitted both files that carry incidental personal
data:
crash.log written whenever tty7 panics — the hook is installed
unconditionally, so nobody opts in. Time, version, panic
message, backtrace. Capped at 256 KiB, never uploaded.
tty7.log only while TTY7_LOG or RUST_LOG is set, and genuinely absent
otherwise. At debug it carries the directories and workspace
names in each request.
A page that enumerates storage and leaves out the two files most likely
to end up attached to a bug report is answering the wrong question, so
both rows say what is in them and what to check before sending them on.
Noticed while reading a debug log for something else: a dependency had
written this machine's hostname and working directory into it.
"What leaves your machine" needed no change — nothing sends either file
anywhere, which is why each row says so.
The test lives next to the panic hook, where a change to what gets
written is a change someone is already making.
The GUI's own log, at debug, still said:
control reader stopping: failed to fill whole buffer
That is the defect fixed one commit ago, at a second site: the client's
reader thread ends the same way the daemon's connection does, and I had
only reworded the daemon. Found by running the GUI rather than by reading
for it — the same shape as fixing one of four throwaway prefixes.
So the wording now lives in one place both sides call, rather than being
copied. It also has to be right for a caller that sees real faults: the
client's reader can fail on a bad frame as well as on a peer going away,
so anything but the four endings std words badly keeps its own text. A
decode failure has nothing misleading to fix, and hiding it would be the
worse bug.
Verified in a fresh GUI's log: both sides now say "peer exited".
With TTY7_LOG=debug, the most common line in the file was:
control connection (unix) ended: failed to fill whole buffer
That is std's phrasing for UnexpectedEof — accurate about the read and
wrong about the event. Every one-shot CLI call ends this way, having sent
its request and read its reply; `tty7 ls` alone leaves one behind. A debug
log is read while hunting a real failure, and this is what a reader would
chase first.
The control path already knew it was not an error: the branch returns
`Ok(())` and logs at debug. Only the words were wrong, and the sibling
branch one line up already says "closed by peer" for the same reality.
Which kind still shows — a reset peer and an exited one are worth telling
apart when a connection dies mid-request — so this names all four rather
than flattening them. The test pins those four; a fifth kind added to
`is_disconnect` and not named here falls to a generic "disconnected",
which no kind reaches today.
`tty7 tab new` in a shell whose workspace has been removed answered:
no workspace with id 65b90fd4-421f-4cfb-9848-d263c9a80959 on this machine
Nothing in what was typed contains a uuid. `workspace_or_context` falls
back to `$TTY7_WS`, so the id came from the shell — and bare like that it
reads as an internal error rather than something to act on. Every sibling
message in this file carries a hint; this one had none.
Found by running the CLI against a daemon that was not the one my shell
belonged to, which is the same shape as the two cases the comment above
`run --keep` already names: a workspace since removed, or a shell opened
against another machine. That insight was in the source and never reached
the reader.
The id stays whole rather than shortened the way an ambiguity is — it is
the exact string to compare against `echo $TTY7_WS`.
The page lists 74 keys and `Config` has 77 fields. The three it leaves
out are right to leave out: `quarantined` is `#[serde(skip)]` and never
reaches the file, and the two frecency maps are written by the app as
you use it, not authored by anyone. Nothing documented is missing from
the struct either — no rot in that direction.
Adding a setting is a field here; the page is four directories away, and
a setting nobody can find is not far off one that does not exist.
The list comes from serialising the defaults, so `#[serde(skip)]` drops
out on its own and the check cannot disagree with the struct about what
a key is called — the same reason the unknown-key warning reads its
names from there. The two exceptions are named in the test, with why.
It also refuses to pass on an empty field list: fifty settings is the
floor, because a cross-file check that reads nothing looks exactly like
one that finds nothing wrong.
Checked the reference against the CLI it describes, in both directions.
It holds today: every flag it names is real — `--h` and `--v` are the
visible aliases of `--horizontal` and `--vertical`, `--all` and
`--orphans` belong to the `pane` subcommands — and every flag of every
verb it covers is on the page. So are the JSON keys: workspaces, agents,
build, control_version, protocol_version, socket, uptime_secs, ports,
procs.
Adding a flag is one line in this file. The page is somewhere else, and
an agent that cannot see a flag will not use it, so the drift is silent
and lands on the readers the page exists for.
The test asks clap for the flags rather than keeping a list beside it —
a list would be a third thing to drift — and skips the `ws`, `tab`,
`pane`, `machine` and `server` groups the page now says it leaves to
`--help`.
It also counts what it looked at. A cross-file test whose filter matches
nothing passes just as quietly as one that finds nothing wrong, and this
one would have: ten verbs and five flags are the floor.
Its description said "Every verb, its flags, and the JSON it emits". It
documents twelve of seventeen: `ws`, `tab`, `pane`, `machine` and
`server` are not on the page, not even in passing.
Writing the missing twenty-odd sections is a documentation project, not
a correction, and inventing them from the outside is how references
start disagreeing with the program. So the page now says what it is —
the verbs a script or an agent reaches for — and points at
`tty7 ws --help` and its siblings for the rest. Those are generated from
the definitions the CLI runs on, so unlike a second copy here they
cannot drift.
My first attempt at that paragraph claimed `ls`, `new` and `split` were
all shortcuts into the groups. Two are: `ls` is `ws ls` and `split` is
`pane split`, both said so in their own help. `new` is not — it makes a
workspace *and* its first tab, where `ws new` leaves it empty — so the
paragraph now says which is which.
`tty7 pane there`, which a grep of the overview turned up, is not a verb
either. It is the middle of "if you are not inside a tty7 pane there is
nothing to split".
`docs/customization/settings.mdx` says "The eight sections" and then
shows one card each. `SettingsSection::ALL` has eight, and the names
line up — Appearance, Terminal, Input, SSH, Agents, Window & Tabs,
Keybindings, About.
Adding a ninth means editing an array in `settings.rs` and a page in
`docs/` that nothing links to it. The count is stated twice there, in
words and in cards, so a section added without touching the page leaves
two wrong statements on the page that explains the window.
Both are now read by the test, the same move as the READMEs' agent count
in the last commit. Not vacuous: the page has no "The nine sections" and
exactly eight cards, so a ninth section fails on both assertions.
Both READMEs say tty7 detects 18 CLI agents. `CLIAgent::ALL` holds 18,
so today they are right — Claude, Codex, Gemini, Aider, Amp, OpenCode,
Copilot, Cursor, Goose, Droid, Pi, Auggie, Hermes, Vibe, Antigravity,
Grok, Qwen, OhMyPi.
Nothing held them together. A nineteenth agent is a one-line addition to
that array, and the number lives in prose in two files nobody edits in
the same change — the front page would go on claiming eighteen and no
gate would notice, which is exactly how the reboot claim in the last
commit got to stay wrong.
So the test reads the other file, the way `keymap` reads its own source
for the action list and `aumid` reads windows-installer.iss. Checked
that it is not vacuous: neither README contains "19 CLI", so the day the
array grows the assertion fires.
README, both languages, and the docs index said that after rebooting
the machine your shells are still running. They are not, and the
project's own agent documentation says so plainly:
When the server goes away — a reboot, a crash, a deliberate
restart — the shells go with it. Panes that were running an agent
relaunch the conversation on restore.
That is the accurate version, and it is what I measured: killing the
GUI leaves the server and every pane alive, and restarting the server
takes panes from two to zero while the workspaces survive. So quitting
the app really does keep shells running — the front page was right
about that half — and a reboot rebuilds the layout around fresh shells
with agent conversations resumed.
The headline is no weaker for being true; "resumes your agent
conversations after a reboot" is the thing that is hard to build, and
it is the thing that actually happens.
Left alone, because they were already exact: "a pane lost to a reboot
relaunches the conversation", prompt history that "carries across
sessions and reboots" (it is on disk), and "session resume after a
reboot".
tty7 ws rm 0c4c9162 # exits 0, says nothing
tty7 ls # 0c4c9162 is still there
Both are correct and together they read as a failed delete. The GUI logs
what it did — "deleted on its machine while a window still had it open;
putting it back under the same id" — rather than leave a window pointing
at nothing. The panes really are gone; what returns is empty, with a
fresh shell and a new name, which is why the codename changed under an
unchanged id.
Nothing said so on the side the reader typed. `ws rm`'s help now does,
including that a workspace with no window on it simply goes.
Measured twice against a live GUI: rc 0, panes to zero, no orphans, the
same id back with a different name, and the GUI still running.
The reconciliation itself is sound and was what I set out to test: a
workspace deleted underneath an open window produces no warning and no
error, and leaves the tree and the pane registry agreeing.
`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.
Swept every snake_case name cited in a comment against the names that
actually appear in code, after nearly committing an invented test name
last commit. Twenty-four came back; twenty-two were fine and only look
wrong from inside this repo — serde's `deny_unknown_fields`, gpui's
`set_maximized`, nu-path's `configurable_dir_path`, git's own
`wt_status_print_state`, zsh's `share_history`, Zed's `paint_layer`.
`render_idle` is real too: it names the `render_idle_gpui_tests` modules,
which a word-boundary search does not match.
The two that were rot:
`read_machine` is `load_machine`, and the sentence around it describes
that function exactly — quarantines the file, hands back an empty tree.
`pane_attachable` was deleted by 412bfcfc, the same commit that wrote
the comment naming it. It meant "the aliveness check we are not doing
here", so it now says that instead of naming a function nobody can look
up.
Both were only findable by comparing comments against code, which is
worth knowing: rustdoc checks `[`links`]` and says nothing about plain
backticks, and these had survived every gate in the repo.
One line in config.json can go wrong two ways, and they were reported in
two shapes:
keybinding for unknown action "NewTabb" ignored
ignoring keybinding for 'NewTab': invalid keystroke 'cmd-shft-t'
Same news about the same setting, quoted differently and built
differently. They now share a shape, so a reader scanning a log does not
have to notice that they belong together.
The third message in this file is a different audience and now says so.
It fires only when the default list offers an action `make_binding`
cannot build — `set_binding` has already turned away anything a config
misspelled — which is a disagreement between two lists in this file, not
a mistake in anyone's config. `every_dispatchable_action_has_a_slot_to_bind_it_in`
exists to keep that from happening; if it ever reaches a user, the log
should not blame their config for tty7's inconsistency.
Both guards verified against a running GUI before and after: a bad
action name and a bad keystroke each named, the good binding beside
them silent.
Nothing else needed here. Unknown actions and invalid keystrokes were
already caught — the same treatment the last two commits gave config
values and unknown keys, arrived at independently in this file first.
A `config.json` holding `font_siz` behaves exactly like one holding a
setting that does not work: the value is dropped and nothing is said.
Confirmed before changing anything — a config with `font_siz`,
`totally_made_up` and an out-of-range `ui_font_size` produced one
warning, about the range.
Unknown keys are ignored on purpose and that stays: a retired key must
not make a whole config unreadable, which
`a_config_still_carrying_the_retired_theme_key_loads` pins. Ignoring a
key and never mentioning it are different things, though, and only the
second one leaves someone staring at a setting that looks right.
The known set is the serialised default rather than a hand-written
list, so it cannot drift from the struct. That trick only holds while
`Config` skips no field on the way out, so the test asks each field
individually whether it would be called a typo — a skipped one would be
reported to its owner as a misspelling, and this fails the day that
becomes possible.
Behind `log_enabled!`, because it parses the file a second time and this
path reloads per pane spawn. Verified: `font_siz` and `totally_made_up`
each named, beside the clamp warning from the last commit.
A hand-edited `config.json` asking for `ui_font_size: 8`, `font_size:
999` and `scrollback_limit: 5` runs at 12, 256 and 100. Both halves of
that are right — clamping beats refusing the whole file over one silly
number, and the file is deliberately not rewritten, the same restraint
the loader already shows toward a config it cannot read. What was
missing is that nothing said so. The file keeps claiming 8 and the app
keeps running at 12, and the two never meet.
Each correction now names the field, what was asked for and what is in
force. The `FontFeatures` parser in this same file has always logged a
tag it could not use; a value quietly replaced is no easier to work out
than a tag quietly dropped.
Logging is off unless `TTY7_LOG` asks for it, so this is silent for
everyone who is not looking — which is what makes it safe to put on a
path that reloads per pane spawn.
Verified against a running server: three warnings, one per clamped
field, each with both numbers, and `config.json` untouched afterwards.
Auditing my own earlier documentation rather than the code, on the
grounds that three defects have already come out of it. This one held
up, and checking it turned up something adjacent.
The restored `CAPABILITY_ENV` comment claims a user's `env` map must not
override `TERM` or `COLORTERM`. Verified by running rather than reading
this time: a config with `TERM=hacked-term`, `COLORTERM=hacked-color`
and `RALPH_OK=yes` produced a pane with `TERM=xterm-256color`,
`COLORTERM=truecolor` and `RALPH_OK=yes`. The filter is exact, and the
rest of the map is applied as written.
Right, and undocumented anywhere the person writing that config would
look: `env` has no entry in the settings docs, and the field had no
comment. Someone who sets `TERM` there gets no warning and no effect.
The reason it is refused belongs next to the field, so it is written
down where the schema is read.
Also checked, and correct: the `ws attach` note from 32f893b describes
`host/server.rs`, and that is the only handler of `WorkspaceAttach` —
the local daemon serves the control dialect through it too, so the note
applies to the local case it was written for.
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.
`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.
`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.
`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.