Commit Graph
1305 Commits
Author SHA1 Message Date
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
l0ng-ai a92aef969d fix(settings): a row you can see is a row search can find
The two halves of settings search did not agree on what matching means.
`row_matches_query` accepts a bare label match, so a row highlights on
its own title; `section_match_count` counts only `settings_search_entries`,
and that count is what drives the nav badges, `best_matching_section`,
and the "nothing matches" note.

Eleven rendered rows were missing from that index, and the result was not
merely that they failed to highlight. Typing a row's own title into the
box made the page answer that nothing matches it, with the row sitting
right there on screen — measured, not inferred:

    "Interface font size"                   -> 0 matches
    "Zoom with the wheel"                   -> 0 matches
    "Skip banner"                           -> 0 matches
    "Shell integration"                     -> 0 matches
    "Import aliases"                        -> 0 matches
    "Custom path"                           -> 0 matches
    "Give each pane its own shell history"  -> 0 matches

All eleven are indexed now, with keywords in the three locales. The SSH
profile form's distinctive rows are among them — someone searching for
"x11" or "proxyjump" is better served by landing on the SSH page than by
being told the feature does not exist. Its four bare field labels
("Name", "Host", "User", "Auth") stay out: each is one common word, and
a one-word entry would put a match on the SSH badge for half the queries
anyone types.

The guard is the durable half. It reads the rows out of this file's own
source, because a hand-kept list would need exactly the discipline the
index needs and would drift the same way — it could not catch the index
doing it. A second test holds the four exemptions to naming rows that
still exist, so renaming one cannot quietly widen the exemption to
nothing.

A label may resolve to more than one key: the blur row is "Background
material" on Windows and "Blur" elsewhere, and the index is `cfg`-gated
to match. So the guard asks whether *any* of a row's keys is indexed
rather than picking the branch the test binary was built for.
2026-08-22 21:41:04 +08:00
l0ng-ai 19baa6af70 chore(lint): leave the workspace building without a warning
`cargo clippy --workspace --all-targets` printed twelve warnings; eleven
were style, and the twelfth was the one that matters — a build that
always warns is a build whose warnings nobody reads, so the next real
one arrives invisible.

`RemoteTerminal::{list_known_hosts, delete_known_host}` are the twelfth.
Their doc already argues they should stay — the daemon half is finished
and only the screen is missing — so the `allow` says the same thing to
the compiler instead of leaving the argument only in prose.

The rest are clippy's own suggestions, taken as offered, except two the
autofix could not make:

- `windows.rs` builds its test `Config` through struct-update now, which
  has to name the inner `CoreConfig`: `Config` is a newtype, and
  struct-update syntax does not travel through `Deref`.
- the input-bar position test iterates the slice instead of indexing it.
2026-08-22 16:52:40 +08:00
l0ng-ai 791d0d0cfa Merge remote-tracking branch 'origin/main' into polish/ralph-wc
# Conflicts:
#	README.md
#	README.zh-CN.md
#	crates/tty7-cli/src/cli.rs
#	crates/tty7-cli/src/server.rs
#	crates/tty7-core/src/core/config.rs
#	crates/tty7-core/src/core/git/status.rs
#	crates/tty7-core/src/daemon/install/wsl.rs
#	crates/tty7-core/src/daemon/protocol.rs
#	crates/tty7-core/src/daemon/spawn.rs
#	crates/tty7-core/src/daemon/ssh/mod.rs
#	src/terminal/completion.rs
#	src/terminal/remote.rs
#	src/ui/app.rs
#	src/ui/i18n/en.rs
#	src/ui/i18n/ja.rs
#	src/ui/i18n/zh.rs
#	src/ui/tree_sync.rs
2026-08-22 16:48:33 +08:00
l0ng-ai 74bb98697d Keep a stalled remote link off the UI thread (#709)
* fix(terminal): keep a stalled remote link off the UI thread

A pane's writing half was a blocking socket with no write timeout, written
to synchronously from gpui event handlers. When the far end stopped
draining — a congested remote workspace, where the router's
copy_bidirectional stops reading our half — the send buffer filled and
write(2) parked in the kernel. One UI thread draws every window, so that
was every window frozen until the link recovered. macOS gives a unix
stream 8K, which is about 1400 keystrokes: a single paste.

Move the socket onto a sender thread. write/resize/respond_auth/Detach
now encode a frame, push it onto a bounded queue and return; the sender
writes with the lock released and is welcome to park for as long as the
far end makes it. A second handle on the socket is kept for shutdown,
which returns at once even while another thread is parked in write(2) —
the only way teardown can break that state.

The backlog is bounded at 4 MiB. Reaching it is a dead link rather than a
slow one, and is reported through the same path — and once — as an
outright refused write. Refusals are now met on the sender thread, so a
pane learns of one a moment after the keystroke rather than during it.

Teardown gives what is queued 50ms to go out before cutting the socket:
on a draining link the sender is idle and Detach leaves in microseconds,
and on a stalled one it never leaves at all, which closing a pane must
not wait to find out.

* fix(terminal): a big paste is a paste, and a retired link keeps its own tongue

Review follow-ups on the pane-writer queue.

The "said it once" flag lived on the pane and was cleared on relink, but
the retiring sender still held the same `Arc`. A doomed write completing
after the reset spent the new link's one chance to speak, and the next
real refusal went unreported. The flag belongs to a link, not a pane, so
`LinkWriter::new` now mints its own.

A frame can be over the whole backlog bound on its own — `paste` sends
the clipboard as one `Input` — and refusing it marked a perfectly healthy
pane gone. An oversized frame onto an empty queue now goes through and
lifts the bound by its own size while it is outstanding, so what queues
behind it is still held to four megabytes.

Also: `close` is idempotent, so the teardown that calls it twice does not
spend two grace periods; a sender that has given up closes the queue
behind it rather than letting keystrokes pile to the bound it will never
drain; and the #673 note that was dropped in the move is back.

The backlog test passed with 27K of margin against a send buffer that is
8K on macOS but 212K on Linux, where the sender discounts what it got
onto the wire — it queues twice the bound now.
2026-08-21 16:05:16 +08:00
l0ng-ai 975e3edf9b Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn

Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.

Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.

"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.

RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".

Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".

Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.

* refactor(completion): stop declaring spec fields nothing reads

The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.

* refactor(daemon): delete the loopback-forward management pipeline

Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.

The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.

known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.

* test(ssh): cover the host-key policy table and both proxy handshakes

The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.

socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.

* test(cli,daemon): cover server binary resolution and the procargs parser

server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.

parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.

* test(ui): cover the host-op pool decisions and the local reconnect schedule

The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.

LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.

* fix(completion): unquote across the whole word, not just its first character

The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.

The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.

* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect

`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.

The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.

* test(file-tree): name the shell where the quoting rule is the POSIX one

`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
2026-08-20 23:33:26 +08:00
l0ng-ai 46759b8a01 fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704)
* fix(input-bar): read column widths from unicode-width, not a hand-rolled table

The input bar scored every character against a hand-written list of code-point
ranges. Anything the list missed counted as one plain column, so `🀄`, `` and
every combining mark pulled the rest of the row a column left, and clicks,
wrapping and the caret all landed off by that much (#701).

The grid gets its widths from `unicode-width` by way of `alacritty_terminal`,
so read the same table. Zero-width characters then need a cell to ride in:
group each base with the marks that follow it, so the shaper sees one run and
composes `é` instead of setting `e` and its accent side by side. An emoji
presentation sequence is re-scored as a string the way the grid re-scores it,
so `❤️` is two columns in the bar as well.

A ZWJ sequence stays two cells on purpose — that is what the grid makes of it,
and composing it here would put the bar a column off from where the text lands.

* fix(input-bar): derive click and wrap geometry from the cells the bar draws

`input_cells` re-scores an emoji presentation sequence to two columns and
hands a stranded combining mark a column of its own, but `input_char_positions`
kept walking the text character by character — so `❤️` was drawn two columns
wide and counted as one. Everything geometric read the short count: a click on
`X` in `❤️X` selected past it, wrapping broke a column early, and vertical
caret motion aimed at the wrong column.

Walk the same cells instead. Only the base of a cell carries the width, so a
click still lands on the base rather than a mark riding on it, and the riders
sit at the column the caret takes after the cell.

A cell now also tints as a unit when a selection covers any character in it —
it is one glyph, so half-highlighting it drew a mark unselected next to its
selected base.
2026-08-20 22:35:32 +08:00
l0ng-ai 07e3b26434 feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)
* feat(agent): outline a coding agent's conversation, and jump back to a turn

The hooks tty7 installs into Claude Code already announce every turn over the
pty as an OSC 777, and the daemon reads those for the pane's status dot. The
same bytes reach the client, where they are worth something else: the byte
offset a `prompt-submit` lands on is a *position in the stream*, so advancing
the emulator to exactly there and reading the cursor gives the scrollback row
that turn began on. That is an outline of the conversation, and a way back into
it — which is the one thing a long agent session in a terminal has never had.

The Info panel grows a CONVERSATION section: one row per turn, the prompt's
first line as its label, a dot that says whether the turn is still running.
Clicking a row scrolls the pane so that turn's prompt is the top line.

Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no
room for one at 260px, and a fourth variant would drop anyone who rolled back
to an older build onto Info. This is a fact about the pane, like its shell and
its cwd, so it sits with them.

The hook is a subprocess writing to the controlling tty while the agent's own
renderer writes to it too. Claude Code repaints in place with ink, so the cursor
when the hook's bytes land is wherever the last repaint left it — inside the
live region, a few rows from where the prompt's echo comes to rest. And once
the scrollback limit starts discarding lines, every anchor slides by the discard
count at once.

So the anchor is a hint, and the prompt's own text is the correction: at click
time (by which point it has long been drawn) the row is looked for around the
anchor, exact match first — the row that *is* `> hi`, marker stripped — and only
then by containment, which keeps its length floor because `hi` appears inside
half the rows of any answer. The row that is found is written back, so a second
click does not search again and cannot land somewhere else.

Claude Code keeps a JSONL transcript, and reading it would give the assistant's
side too. It would also only work for Claude, only when the agent runs on this
machine, and only for a path this process may read. An OSC comes back through
the pty from wherever the agent actually runs — over ssh, in a container, in a
remote workspace — with no file access and no per-agent format. What is lost is
the assistant's text; what is kept is every host tty7 supports.

- `OscTokenizer::feed_at` reports each payload's end offset. The client already
  tokenized OSC 777 on every batch to keep agent events out of desktop
  notifications, so the scan is free; only a real event now costs a cut, which
  is what #404 was right to object to about the old per-command mark scanner.
- `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending
  runs concatenated are not one, so a batch carrying both kinds is sorted —
  and only such a batch pays for it.
- A replayed ring is cut the same way, so reattaching to a pane rebuilds the
  outline from its own history rather than losing it with the old client.
- Turn anchors are dropped where image placements are: `clear_scrollback`, and
  the grid reset in `adopt_relink`.
- A turn that began on the alt screen is listed but not clickable — there is no
  scrollback behind it to return to.
- A turn announced twice is one turn. Hooks are not guaranteed to fire once,
  and what makes it the same turn is that the one before it never ended: a real
  repeat can only come after an answer, and an answer brings a `stop`.
- The hook forwards the prompt's first line, clamped to 200 characters. The
  tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a
  pasted file would otherwise cost the whole event; and a needle spanning a line
  break matches no single row.

No protocol change: the prompt rides in the OSC the hook already sent, and an
older client ignores the field.

* refactor(panel): drop the Info panel's agent row

It said `Claude Code · working` behind a status dot — the same name and the
same dot the tab chip and its sidebar row were already wearing, restated two
panels away from either of them. The CONVERSATION section that now sits under
it says what the agent is doing in a form the row never could: which turns
there were, which one is still running, and a way back to each.

`InfoValue::Agent` and `status_pip` went with it — the dot was the row's only
caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three
status labels stay: the tray menu still names them.

`Tab::agent_row` stays too. `agent_status` is that pair's status and the tab
strip's badge reads it, which is the one-leaf rule #543 put there.
2026-08-20 21:56:34 +08:00
l0ng-ai d8ac3ef454 docs(readme): fix the fork column and restate the feature tables
The support matrix left Fork blank for Droid, Qwen, and Goose, but all
three have a fork command in CLIAgent::fork_label and hooks in
HookAgent::ALL, so the menu entry is reachable. Amp stays blank: it has a
fork command but no hooks, so no session id ever arrives and can_fork
never turns true — which the intro paragraph now states.

Also restores "click places the caret" and IME to the input and window
rows, folds the prose that had crept into the Agent-aware, CLI, and Git
cells back into scannable fragments, and drops the Why lede's repetition
of the three bullets directly beneath it.
2026-08-20 21:49:31 +08:00
l0ng-ai 024d368925 docs(skill): restructure the agent skill around a delegation playbook (#702)
* docs(skill): restructure around a delegation playbook

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

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

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

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

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

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

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

- A turn aborted by an API error emits no turn boundary, so the status
  stands at 'working' forever and wait sleeps through it. Diagnose from
  the screen's error line; recover by telling the still-alive interactive
  session to continue.
- A short capture tail cuts off the spinner line and shows only the TUI's
  always-present input box, which reads as idle. Tail 15+ lines and read
  for the spinner; 'bottom looks like a prompt' is only evidence on a
  shell pane.
2026-08-20 21:02:11 +08:00
l0ng-ai 8a950a343b Say what this platform does, not what macOS does (#700)
* fix(i18n): say what this platform does, not what macOS does

Four pieces of user-facing wording described macOS as if it were the only
platform they were read on, in all three languages at once — each
translation had faithfully carried the English text's assumption across.

- Copy on select claimed "no ⌘C needed" everywhere. Off macOS the binding
  is Ctrl+Shift+C, so the sentence named a key that copies nothing.
- The blur switch was labelled "(macOS)" on a row Linux also renders and
  also honors. Windows gets the backdrop picker instead, so the label was
  wrong for every reader it had. It now says which compositors deliver it,
  because gpui's X11 backend does no blur at all and Wayland only does
  when the compositor offers a blur manager.
- X11 forwarding named XQuartz as the only prerequisite anyone could have;
  Windows needs an X server of its own and Linux needs nothing.
- The Explorer verbs were string literals, so a Chinese or Japanese
  install got English context-menu entries for the life of the install.

The Explorer labels are the one string in the product that outlives the
process that wrote it: Explorer reads them from the registry, not from
tty7. Registration now sets the locale before building the entries (that
process returns before the GUI path's set_locale ever runs), and a
language change in Settings restates them. Only keys that already exist
are rewritten — offering the menu is the installer's checkbox and
declining it is the user's, and changing a language must never be what
puts the verbs back.

* docs(settings): the blur description no longer says what this comment quotes

* fix(config): restate the Explorer verbs when a hand-edited language changes
2026-08-20 20:51:36 +08:00
l0ng-ai 8131ac2f93 Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
2026-08-20 18:28:48 +08:00
l0ng-ai 47e25ef854 fix(ci): judge a Mach-O's signature by codesign's exit status (#696)
`codesign -dv` spells its signature line differently per posture:
`Signature=adhoc` for an ad-hoc or linker signature, `Signature size=8968`
for a Developer ID one with a timestamp. The check matched the literal
`Signature=`, which the second spelling does not contain.

While the script only pointed at the standalone tty7-server, which is
ad-hoc signed, that was invisible. #692 pointed it at the bundle's
tty7-app, tty7 and tty7-updater as well, and those are Developer ID
signed whenever the signing secrets are present. Pull requests do not see
the secrets, so every PR run took the ad-hoc branch and passed; the first
build that signed for real — the nightly — failed on all three binaries,
printing `CodeDirectory`, `Signature size=8968` and a Developer ID
`TeamIdentifier` as its proof they carried no signature. The binaries
were signed, notarized and stapled; only the assertion was wrong.

Exit status has no such split: 0 for anything signed, 1 with `code object
is not signed at all` for anything not, verified against all three
postures. The output is still captured so the failure message carries it.
2026-08-20 14:00:40 +08:00
webdevandl0ng-ai 51b0fe64b9 fix(linux): stop the compositor framing a window that draws its own title bar (#679) (#683)
* fix(linux): stop the compositor framing a window that draws its own title bar (#679)

tty7 paints its own title bar through gpui-component's TitleBar, and the
WindowOptions it opens with say as much (appears_transparent) — but say
nothing about decorations. gpui reads a missing window_decorations as
WindowDecorations::Server and, on Wayland, sends
zxdg_toplevel_decoration_v1.set_mode(server_side) for the toplevel, so a
compositor that honours it draws a second title bar and border around the
one the app already has. window_options() now asks for
WindowDecorations::Client, which is what Zed defaults to (its
window_decorations setting, overridable by ZED_WINDOW_DECORATIONS).

Nothing new is painted for it. gpui-component's Root already wraps the
window in window_border() — bordered defaults to true and tty7's root
never turns it off — which under Decorations::Client draws the 1px frame,
the 12px shadow, the resize hit bands and the right-click window menu, and
tells gpui its inset through set_client_inset; under Decorations::Server it
degrades to a plain div. The request was the only piece missing.

The field is set without a cfg, unlike the icon beside it: the icon is
gated because the PNG behind it is only decoded on Linux, while this is a
plain enum that costs nothing elsewhere. request_decorations is an empty
default on the PlatformWindow trait that neither the macOS nor the Windows
backend overrides, so both keep answering Decorations::Server and the
window there is unchanged. X11 turns the request into _MOTIF_WM_HINTS and
falls back to server-side on its own when no compositor is running, so a
bare X session still gets a window-manager frame — and a reparenting WM
under a compositor, which today is told in the same hints to decorate,
gets the same fix as Wayland.

Client-side decorations bring one follow-on that Zed hit too
(ca9cee85e1, "linux: Fix non-maximized Zed windows growing larger across
sessions", #22301), and the two Linux backends want opposite answers to
it. The bounds tty7 remembers go back in through
WindowOptions::window_bounds, which every backend reads as the outer
rectangle. On Wayland under client decorations the outer rectangle is
the surface, shadow included, and the compositor's first sized configure
adds the inset back onto whatever was asked for (compute_outer_size) —
so saving outer and reopening at it grew the window by twice the shadow
per launch, and saving inner pre-deflates by exactly what the configure
re-inflates. X11 never re-inflates: it creates the window at the
requested rectangle verbatim, and its inner_window_bounds also shifts
the origin by the inset, so saving inner there would shrink the window
and walk it down-right by the shadow on every launch wherever the
request is honoured (a compositor plus _GTK_FRAME_EXTENTS — GNOME on
Xorg, Plasma X11). A window_bounds_to_remember helper therefore saves
inner on Wayland and outer everywhere else, told apart by
cx.compositor_name(); macOS and Windows report no inset, so the two are
the same there. WindowState round-trips unchanged.

The one place that hardcoded the window's corner follows the frame: the
pane-to-tab-strip drop band was a rectangle from (0, 0) to the title
bar's height, which under client decorations is the shadow strip plus
the top of the bar, missing its lower third. It now starts at
window_paddings(window), which is zero under server decorations, so
nothing moves off CSD.

A test pins the request: window_options() must answer Some(Client), and
its title bar must be the transparent one the request stands in for.

Not verified here, with no Linux session to run in: that the reporter's
compositor honours the mode switch (the protocol lets it refuse), how the
12px shadow reads against the shipped themes, the edges of a maximized or
tiled window, where gpui-component drops the padding on the tiled sides,
and one quit-and-relaunch on X11 under a compositor to see the remembered
size hold. The app.rs change is untestable in principle: gpui's
TestWindow overrides neither inner_window_bounds nor the decorations, so
inner and outer are one rectangle in every test. FreeBSD runs the same
backends with gpui-component's shadow at zero; unexercised.

* fix(linux): remember the inner window bounds on X11 too, not just Wayland

The bounds tty7 remembers were saved as the *outer* rectangle everywhere
but Wayland, on the reading that X11 creates its window at the requested
rectangle verbatim and never puts the shadow back on. That reading is
wrong, and it reintroduces on X11 exactly the bug the split was written
to avoid on Wayland.

gpui only turns client-side decorations on for X11 when a compositor is
present *and* the window manager advertises _GTK_FRAME_EXTENTS
(client_side_decorations_supported in x11/client.rs). A window manager
that advertises that atom is one that honours it — it keeps the visible
frame put and treats the extents as shadow outside it — so a window
reopened at its outer rectangle comes back one shadow larger on each
side, every launch. That is what Zed measured: ca9cee85e1 ("linux: Fix
non-maximized Zed windows growing larger across sessions", #22301), the
commit this code cites, took all of its before/after numbers on X11
(+20px per session) and fixed both backends with a single unconditional
inner_window_bounds(). Zed still reads it unconditionally today, at the
rev pinned here.

So drop the compositor_name() branch and save the inner rectangle on
every platform, as Zed does. It is a no-op wherever there is no inset to
strip: inner_window_bounds defaults to window_bounds on the
PlatformWindow trait and neither the macOS nor the Windows backend nor
gpui's TestWindow overrides it, and on X11 without a compositor
window_decorations() answers Server, so the window border never calls
set_client_inset and last_insets stays [0, 0, 0, 0].

Also lift the tab strip's drop band out of the render path into
strip_band(), so the padding arithmetic can be tested without a window:
the viewport measures the whole surface, shadow included, so the band
loses one padding at each end rather than one twice over or none at all.
It clamps at zero now — a surface narrower than its own shadow is only
reachable mid-resize, but a negative width would hand Bounds::contains a
rectangle that is inside out.

Three tests: the band is unmoved when the frame reports no padding (macOS,
Windows, a bare X session), it reaches the far edge of the frame rather
than of the surface when it does, and it collapses instead of inverting.
window_bounds_to_remember stays untested on purpose — TestWindow makes
inner and outer the same rectangle, so any assertion about it would only
restate the call.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 10:14:49 +08:00
Austin Spragginsandl0ng-ai 2cdc26f357 Wire hooks, resume and detection for Kimi Code CLI (#694)
* feat(agents): wire hooks, resume and detection for Kimi Code

Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.

Closes #693

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>

* fix(agents): harden the Kimi Code TOML hook merge and its resume flags

The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.

`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.

A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.

`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.

On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.

Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.

---------

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 09:51:05 +08:00
Wh1teandl0ng-ai e82a460794 fix(windows): normalize path separators before reveal and copy (#680)
* fix(windows): normalize path separators before reveal and copy

Open-folder (reveal_path) and copy-to-clipboard hand raw paths to gpui.
On Windows, mixed-separator paths (a forward-slash prefix joined with
backslash entries) reach reveal_path through two routes:

  - the shell's PWD — OSC 7 from Git Bash / MSYS bash reports `/`, and
    that string survives `Path::ancestors()` when the file tree walks up
    to find `.git`, so the file-tree root keeps the forward slashes
    while `read_dir` entries underneath it come back native (backslash);
  - `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which
    always prints `/` regardless of the calling shell. The SCM panel's
    `scm_repo_root` and the worktree creation in tty7-core both use it,
    so the root they hand downstream is `/`-prefixed and joins against
    backslash-joined entries to form `D:/code/tty7\skills`.

Windows' IShellFolder::ParseDisplayName rejects that with E_INVALIDARG
(0x80070057); reveal_path swallows the error (it only logs), so "open
folder" silently does nothing. The same mixed-separator paths also make
copy-to-clipboard produce strings the user has to retype before a shell
will accept them.

Add a native_separators helper in path_display and apply it to every
reveal_path call (file tree, scm panel, right-panel info cwd, sftp
downloads) and to every path copied to the clipboard.

* fix(windows): rewrite separators losslessly, and only for local paths

Review follow-ups on the reveal/copy separator fix.

`native_separators` went through `to_string_lossy`, so any path holding an
unpaired surrogate — legal in an NTFS name, not representable in a Rust
`str` — came back with `U+FFFD` in place of it, naming a different file.
Since `reveal_path` only logs its failures, that reads to the user as the
same silent no-op the fix is here to remove. It now maps over the path's
own UTF-16 code units and rebuilds with `OsString::from_wide`; `/` and `\`
are ASCII, so a unit equal to either is that character and never half of a
surrogate pair. Still `Cow::Borrowed` when there is no `/` to rewrite.

The three clipboard sites re-spelled remote paths too. File-tree "Copy
path" and the SCM panel's sat outside the locality guard their Reveal
neighbours sit behind, and the Info panel's cwd copied `effective_cwd`
while its Reveal checked `local_cwd` — so a Windows window onto a remote
Linux host copied `/home/u/src` as `\home\u\src`, which names nothing on
either machine. Each now shares one locality check with its Reveal.

Both "Copy working directory" entry points were missed entirely: the
app-menu action and the tab context menu each spelled the path their own
way. They now share `tab_cwd_text`, which applies the same rule.

Adds a Windows test that a lone surrogate survives the rewrite.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 09:47:26 +08:00
webdev edfea5b830 ci(macos): assert every Mach-O in the bundle is the arch it ships as (#687) (#692)
A macOS 26 user opened the Apple Silicon build and was told it "contains
Intel parts" (#687). Downloading what is actually published — v26.8.2,
v26.8.3 and the nightly after #605 — and reading every file's Mach-O
header says otherwise: the three binaries under Contents/MacOS are thin
arm64, nothing else in the bundle is Mach-O at all, and tty7-app's load
commands are all /System/Library/Frameworks and /usr/lib. The build is
right today. The likeliest reading of the warning is macOS pinning an
x86_64 program someone ran in a pane on tty7.app as the responsible
process — the same attribution bundle-macos.sh already documents for
TCC — and that belongs on the issue, not in this change.

What does belong here is that nothing would have caught it if the report
had been right. assert-macho.sh knows how to say "this is a 64-bit
Mach-O for <arch>, it links only what macOS ships, and it is signed", and
since #605 it has said it — about the standalone tty7-server asset, and
only that. It has never been pointed at anything inside the .app. A
helper built without --target on an Intel runner, a dylib dragged in
from /opt/homebrew, a universal binary from a toolchain that decided to
be helpful: each would have zipped, notarized and shipped, and the first
check would have been a user's Finder.

So check the bundle, in bundle-macos.sh, where release.yml and
nightly.yml both build it. After the signing block — assert-macho.sh
insists on a signature, and this way one pass covers Developer ID and
adhoc alike — and before the update zip and the DMG, so a bundle that
fails never becomes an artifact, and before the `mv` that dissolves
dist/tty7.app. First the binaries the script staged itself: tty7-app,
tty7 and, when it is packaged, tty7-updater, each through
assert-macho.sh at the full standard the server asset is held to. That
also leaves every shipped binary's load commands in the release log,
which is where the next report of this kind gets answered from. Then a
sweep of every file in the bundle: `file` says which are Mach-O of any
kind, `lipo -archs` names the slices in each, and the answer has to be
exactly the matrix arch. Any other name is the wrong build; two names is
a universal binary, which is what the report described. lipo judges
rather than a parse of `file`'s prose because Apple's `file` and
upstream libmagic word the arch differently and lipo's slice names do
not move. A sweep that finds fewer Mach-Os than the binaries staged
above fails as well, so a changed wording cannot quietly turn it into a
no-op.

On a Developer ID build this runs after notarization, which spends a few
minutes of notary time on a bundle that was never going to ship. Cheap
next to carrying a second copy of the block inside each signing branch.

Deliberately not a fix for what the reporter saw, if it is the
child-process attribution: no check at build time can speak for a binary
the user runs inside a pane. What it guarantees is narrower and worth
having — the bundle named arm64 contains nothing but arm64, and a release
where that stops being true fails on the runner.

Validated with bash -n and shellcheck, and by running the sweep — and
the whole script in its adhoc posture — on Linux against fake bundles
with file, lipo, otool, codesign, ditto and hdiutil stubbed: a clean
bundle passes and packages; a wrong-arch updater, a universal tty7-app,
a stray x86_64 dylib, an arm64e nested bundle and an empty bundle each
fail and name the file, and nothing is zipped after a failure. Not yet
run on a Mac; the next nightly is what answers that.
2026-08-20 09:30:42 +08:00
webdev 010457132f fix(terminal): shape a regional-indicator pair as the one flag it is (#686) (#691)
A flag such as 🇨🇳 is two Regional Indicator symbols, U+1F1E8 U+1F1F3.
Each is width 1 to unicode-width, so the grid gives each its own column
and no spacer: the pair already sits in exactly the two columns a flag
occupies. But `segment_row` sent each one to `Solo`, and a `Solo` is its
own `shape_line` call. The shaper never saw the two together, so it had
no chance to form the flag ligature, and each half came out as the
letter-in-a-box glyph an emoji face draws for a lone indicator. A `Solo`
also clips to two cells so a fallback face's advance has room, and that
box is two cells wide, so each half spilled into the next column as well.

Join a Regional Indicator and the one after it into a single two-cell
`Cluster`, the move a7835a0 made for SARA AM: two width-1 codepoints
that own a column each but are not atomic to the shaper. The check sits
ahead of the marks branch so a stray mark on either half (a VS16 on the
first, say) rides along in the cluster text instead of splitting the
pair — split, the other half paints alone as a box again.

`wide_base` stays false. With the ligature there is one glyph at
position zero and the pinning is moot. Without it — a font that lacks
the flag — the shaper returns two glyphs, and `force_width = cell_width`
pins the second into the second column, where it stays visible — the
two legible halves such a setup shows today. `wide_base: true` would
pin it at `2 × cell_width`, past the cluster's two-cell clip, and
swallow half the pair.

The edges fall out of the scan. An indicator in the last column has no
partner on its row and stays `Solo`; two halves of a flag on different
rows cannot be joined, and drawing them apart is the honest answer.
Three in a row pair greedily left to right, which is UAX #29's rule for
them. A style change between the halves keeps the cluster under the
first cell's style, as for SARA AM: a recoloured flag beats two boxes.
Selection, copy, cursor placement and reflow read the alacritty grid,
not `RowSeg`, and are untouched.

A grid-level test feeds 🇨🇳x through the emulator, `snapshot_cell` and
`segment_row`, pinning the premise that each indicator lands in one
plain column. A unicode-width or alacritty bump that changes that fails
there rather than misdrawing quietly.

Out of scope: skin-tone modifiers and ZWJ sequences. Those are a
grid-width problem — alacritty reserves four or six columns for them —
and nothing here touches them.
2026-08-20 08:56:09 +08:00
l0ng-ai 958d8b7442 feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)

Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.

They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.

`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.

- Half the terminal column by default; drag the divider, double-click it
  to cycle a third / half / two thirds, or use the palette commands. Two
  thirds deliberately runs past the half-window cap the side panels obey
  — only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
  the way they already reserve each other, and the column is derived from
  the *live* sidebar and panel widths rather than their floors, so a
  panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
  derived at render time and never stored, so widening re-docks on the
  next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
  file over the whole window in one tab while an agent keeps half of
  another is the normal case, and one global switch made each of those
  flip the other. A tab that has not been told reads `document_layout`
  from the config, which is what a fresh tab starts as — and which the
  menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
  bar's height of nothing above the column. The header is drawn into it,
  and behaves like the title bar it now sits in. With the detail panel
  closed the column reaches the window's right edge, so the header stops
  short of the trailing chrome through a width the tab strip's own
  reservation shares.
- The docked headers drop the traffic-light inset they never had to
  clear, and the diff header's branch name becomes the thing that yields
  so the view toggle and the close tile survive a column's width.

New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.

* fix(window): hold the docked column to widths the strip and the file agree on

Three defects in the document column, each with a guard test that fails
without its fix.

The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.

The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.

The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.

Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
2026-08-19 18:03:51 +08:00
l0ng-ai 7bcb91d8af fix(input): give the PTY back the Ctrl chords tty7 was eating (#684)
* fix(input): give the PTY back the Ctrl chords tty7 was eating

Follow-up to #682, which handed Ctrl+V to a full-screen program but left
three neighbouring holes of the same shape: a key the terminal answers
without the keymap ever seeing it.

The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]`
and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate
file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and
Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were
silent: gpui filters control characters out of `key_char` on all three
backends, so the text fallback had nothing to offer either. The table is
now the VT-220 one, each digit beside the punctuation that shares its
key, because every platform hands Ctrl+Shift+6 over as `^` with the
Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and
is spelled out with the reason. The twenty-six letters fold to `& 0x1f`.

`on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`,
left over from when tabs lived on ctrl-digits — they have been on
Alt+1..9 for a long time, so nothing claimed those chords and the block
only deleted keys. It also sat before `keystroke_to_bytes`, so not even
the kitty protocol got through it. Gone.

Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in
a `Terminal && !alt_screen` context, and the pane declares `alt_screen`
whenever a full-screen program owns the grid, so the behaviour #682
settled on is unchanged — paste at a prompt, SYN inside vim — while the
keymap can finally express it, the Keybindings page lists it, and the
user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell
everywhere, including readline's `quoted-insert`, and
`"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal
does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on
purpose, as a removable binding, for exactly this reason. The hardcoded
arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's
only paste chord and carries no control code to lose.

Last, the rule about control codes is one function instead of an
assertion buried in a test. `steals_a_control_code` plus a commented
`control_code_binding_allowed` back both the defaults test and a new
runtime warning, so a hand-edited config.json that takes EOF away from
every shell says so in the log. It warns rather than refuses: a chord
the user asked for by name is theirs to spend, the way the tmux preset
spends Ctrl+B. The invariant that still fails a build is that no
*default* spends one silently.

Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake
(`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a
rerun and on a clean tree). New: the whole VT-220 table asserted byte by
byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`,
`ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn`
drive the real keymap through `simulate_keystrokes` rather than calling
into the view; the keymap tests cover both escape hatches and the
context that withholds the binding. #682's two `handle_cmd_shortcut`
tests are replaced by those three, which assert the same behaviour at
the layer that now decides it; its end-to-end SYN test stands unchanged.
The gpui tests are unix-only, so CI is what runs them.

* fix(input): ask the grid, not the last frame, before Ctrl+V pastes

`AlternatePaste` carries `Terminal && !alt_screen`, but gpui matches a
keystroke against the frame it last painted, so the context outlives the
switch: a full-screen program that took the screen after that paint is
still "at a prompt" as far as the keymap is concerned, and the clipboard
lands in it. In vim's normal mode that runs as commands. The action now
re-reads the terminal mode and propagates instead, which hands the chord
to `on_key_down` and encodes it as the SYN the program is waiting for.

Also:

- the two escape-hatch assertions in
  `paste_ships_both_terminal_chords_off_macos_and_retires_together`
  built a one-entry binding table instead of the default one, so both
  passed without the hatch working — an emptied `AlternatePaste` cannot
  dispatch anything when it is the only entry in the table. They now
  apply the config line on top of the whole default table, and the
  `PasteText: ctrl-v` case checks both screens;
- the keyboard-shortcuts page claimed every other Ctrl chord reaches the
  program, which Ctrl+Tab and the Windows/Linux font-size chords do not;
- `steals_a_control_code` documents `@` and the backtick, which are in
  the set it walks but were not in the list beside it.
2026-08-19 17:38:28 +08:00
webdev f44b667639 fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681)
A restart on nightly 26.8.4 came back with every restored coding-agent
pane locked: Ctrl-Z printed its suspended message and never returned to
a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its
sibling — a restart after an upgrade that came back to an empty
workspace (#672) — was mostly closed by #554 and #579; what is left of it
is closed here too, because both are the same mistake, a restart's
rebuild reporting a success it did not have.

The locked panes are an `Attach` the client took on trust.
`attach_reply_prefix` reads far enough into the daemon's reply to tell an
`Error` frame from a replay, and a read that timed out with nothing in
the buffer fell through to the success branch: silence was read as "a
quiet pane". But a quiet pane is never silent. `attach_subscriber`
replays the pane's ring before the daemon reads a byte of our input, the
ring always holds a segment (`ReplayRing::new` starts with one and every
path that empties it puts one back), and every daemon build there has
been queues a `Size` and then a `Snapshot` first — a pane that has
printed nothing still answers with its geometry. So an `Attach` that
produced no bytes in the whole wait is one nobody is serving: a daemon
still mid-restart, or a socket some process holds open and will never
read. Taken for an attach, it made `spawn_shell_terminal_in` report
`restored = true`, the flag that skips the fresh spawn, the
restored-screen banner and the agent's `--resume`; and `write` threw
every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went
into that socket and vanished. Zero bytes is now the failure it is, and
the caller falls through to the path it already had for a pane that is
gone — a fresh shell under the old screen, with the resume typed.
Nothing changes on the wire.

That silence has a second reading, though, and only one of the two is
safe to act on. A daemon merely slow to serve — an execve handoff keeps
the listener and its backlog across the exec, and a fresh daemon adopts
its panes and seeds ids before it takes an Attach — would have served
the connection a moment later, and a fresh pane spawned over that live
one carries its history across (`history::carry` is written for a dead
pane) and starts the agent's resume against a session the old process
still holds. So a silent local Attach is confirmed before it is acted
on: the client asks the daemon `Version` on a fresh connection, which a
daemon answers before it touches any state. Answered, the daemon is up
and serving and the attach socket is one it will never serve — the
verdict stands. Unanswered too, nobody is serving yet; there is no third
path from a synchronous UI-thread call, so the attach still fails, but
the error and the log line say which silence it was rather than
claiming the pane is gone, since that is the line someone reads while
diagnosing an orphaned shell. Only local routes probe: a remote attach
already waits fifteen seconds and a second routed connection is a
second bridge process. The two-second local budget is unchanged — only
a silent connection ever pays it, and N silent panes hold the window
still for N of them.

`write` also stops swallowing the link refusing input. The first refusal
is logged once from the writing side, and unless the reader was retired
for a relink the pane is marked exited by the reader's own signal —
`exited_flag`, then the `Exit` event — since it is the same socket, only
found dead from the writing side first; the reader still raises its own
when it gets there, and the handler is idempotent. A retired link stays
quiet, for the reason the retired reader does. This is hardening for a
closed link, not the cure for #673 — a socket held open and never read
accepts writes into its buffer, and nothing here fires; the attach
change is what keeps that pane from existing.

The tabs that did not come back are the rebuild's licence outrunning
what it rebuilt. `tabs_from_session` drops any tab none of whose panes
would start; `settle_hydration` then marked the window `informed` as long
as *some* tab rebuilt, while the mirror it had just installed still
listed every tab the machine holds. The next `sync_window` ran at
`SyncScope::Full`, and `diff` at that scope emits `TabClose` for every
mirror tab not in `desired` — which the dropped tabs were not, and `held`
did not cover them: it only covers tabs on screen whose panes cannot be
represented. A partial rebuild deleted from the machine exactly the tabs
it had failed to rebuild, panes and all.

They are held now, rather than the licence withheld. `settle_rebuild`
records the wanted ids the window is not showing (`not_rebuilt`), and
`sync_window` carries them into `held`, whose contract in `diff` is
already "mirror tabs the window cannot speak for — close nothing, and
do not reorder around them". Withholding the licence would have been
the smaller change, and it is what the none-rebuilt case does, but it
takes `TabClose` away from the whole window for as long as the failure
stands, and a failure can stand across every restart (a tab whose shell
is no longer on the machine): every close the user made in the meantime
would come back on the next rebuild. Holding only the tabs that failed
leaves the window speaking for the ones it did put up. The set is
rewritten by the next rebuild and pruned against the mirror on every
sync, so a tab the machine lets go of stops being held. The none-rebuilt
guard is unchanged: a window that put nothing up still does not speak
for the workspace at all.

Two things about the held set said out loud. It reads the count of tabs
the tree asked for, not the ids it found: `tree_id` is not serialized,
so a session that reached this path from disk would name no ids, and
"no ids" must not read as "no tabs wanted" — that would hand the licence
to a window that rebuilt nothing, which is #672 again. And holding has a
cost with no retry: `diff` stops before its reorder pass and the
active-tab op whenever anything is held, and nothing rewrites the set
but the next rebuild — a re-prime and an `IfEmpty` hydrate on a
populated window never get there — so a tab that fails to rebuild holds
the window's tab order and active tab off the machine until the next
restart. That state was already reachable, since a pane whose remote
spawn failed stays connecting for the same span, held the same way; this
widens a standing hole rather than opening one, and a retry, or a way to
close a held tab from the window, is separate work.
2026-08-19 14:24:12 +08:00
webdev 3c95995e82 fix(input): hand Ctrl+V to a full-screen program on the alternate screen (#677) (#682)
In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where
the editor expected blockwise Visual mode. Windows Terminal (with its
ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS
was never affected, since Cmd+V is the paste chord there.

Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V
and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm
was the only unconditional one: Ctrl+C copies with a selection and
otherwise falls through to SIGINT, Ctrl+X falls through outside the
editor, but Ctrl+V always consumed, so SYN never reached the PTY --
`input.rs` had the byte, unreachably -- and an empty clipboard turned the
key into nothing at all. #270 set the rule that off macOS ctrl-<letter>
belongs to the terminal and anything sitting on one must fall through;
Ctrl+V was the exception that had escaped it.

The arm is now contextual like its neighbours. On the alternate screen
it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form
when the program has the kitty protocol on; off it Ctrl+V pastes exactly
as before, and Cmd+V on macOS is untouched. The alternate screen is the
gate rather than `input_active` because the editor is inactive whenever
shell integration is missing or the prompt editor is off, and gating on
that would take paste away from every such user; a program that has
switched screens is precisely the case reported. Inside such a program
paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of
which still stage a clipboard image for an agent.

The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the
hardcoded path whenever the keymap had nothing on them -- exactly the
state rebinding Paste leaves behind, which #271 promised would retire
Ctrl+Shift+V, but it went on pasting behind the user's back. Only
unshifted chords enter the block now; the shifted ones are the keymap's
alone.

The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as
though they were the bindings, next to a Select All row that already
showed its hint on macOS only. The three rows take the same treatment,
which is also what the command palette does.

Three view tests pin the split -- Ctrl+V falls through on the alternate
screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key
down on the alternate screen arrives at the PTY as SYN and nothing else
-- and the keymap's paste test now asserts that no default claims ctrl-v
in the Terminal context. The shortcuts reference notes where plain
Ctrl+V pastes and where it is the program's.

Fixes #677.
2026-08-18 23:28:01 +08:00
l0ng-ai ef333bf055 feat(terminal): make the wheel-zoom modifier configurable (#676)
Cmd-scroll zoomed the font with no way to move it or switch it off, so a
thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is
now a setting: the platform modifier by default, or Ctrl, Alt, or none.

Stored as the choice rather than the resolved key, so one config file
still means the same thing on a Mac and on a Linux box. Settings ->
Terminal -> Mouse carries the picker; off macOS Ctrl and the platform
modifier are the same key, so it shows one cell for them.
2026-08-18 12:16:17 +08:00
l0ng-ai 8b5aeb0077 Wire hooks, resume and fork for the CLI agents that support them (#666)
* feat(agents): hook, resume and fork support for nine more CLI agents

Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their
own settings.json the way Claude and Codex already do; Goose gets an
owned file under the Open Plugins layout it implements. Qwen is the only
one of them with a first-class PermissionRequest event, so it needs none
of the notification sniffing the others do -- and deliberately gets no
Notification hook at all, since that event fires for non-blocking alerts
too and would strand a pane on "waiting".

Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork`
is a real subcommand that is simply missing from `amp threads --help`.

Four detection and replay bugs turned up while checking each CLI:

- `python3 -m antigravity`, the documented way to trigger Python's own
  easter egg, was detected as a coding agent. The `antigravity` binary
  is the IDE's launcher shim anyway, in the shape of VS Code's `code`,
  not the terminal agent -- that one is `agy`.
- Amp lost every launch flag on resume. It names a thread with a
  positional argument, so the stale-flag list had nothing to drop and
  the generic bare-token check rejected the whole tail along with it.
- Gemini could be handed a command line it refuses to start from:
  `--session-id` and `--session-file` are mutually exclusive with
  `--resume` and were never stripped.
- Cursor's `--continue` was not stripped either, leaving it to collide
  with the injected `--resume <id>`.

Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now
come from first-party sources -- logo SVG fills and site CSS variables
-- rather than approximations. Qwen ships its real mark instead of the
generic bot glyph.

Hooks stay unwired for Aider (no lifecycle mechanism exists at all),
Cursor (its usable events gate permissions, and tty7's silent hook would
read as a failed check and auto-allow the command), Auggie (its command
field takes only script paths, needing generated wrappers, and the
constraint could not be verified without a billed run), and for Hermes,
Amp, Vibe and Antigravity, whose event sets are too thin to report a
blocked turn.

* fix(agents): strip every session-naming alias before replaying launch flags

Goose spells --session-id also as --id, --name as -n, and keeps a legacy
--path, all in one exclusive clap group; Qwen rejects --session-id next
to --resume; Vibe shortens --continue to -c. Any of these surviving a
replay broke the regenerated resume command. Qwen's --no-chat-recording
also persists nothing, so it now opts the pane out of resume and fork
like Auggie's --dont-save-session. The Qwen icon gains the 24x24
width/height every other agent mark carries.
2026-08-18 00:42:56 +08:00
l0ng-ai 9c2869a25f Trim the app's long-winded copy, add four dark themes (#663)
* refactor(i18n): drop the About page shell primer and trim the long copy

The About page carried a "How shells work" section explaining that shells
live in a background server. Nothing linked to it and the Updates and
Server sections below already say what happens to those shells, so it was
a paragraph of prose the page did not need. Remove it, its search index
entry, and its three L10nKeys.

Then cut the padding out of 48 strings across settings rows, dialogs and
notices. Two patterns accounted for most of it: the restart-server
dialogs stated "your shells keep running" up to four times each in
different words, and the config.json failure notices packed three
subordinate clauses into every sentence.

Nothing is dropped but repetition and clauses the reader can infer —
every consequence a dialog asks the user to weigh is still spelled out.
en, zh and ja stay in sync.

* feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night

Four more dark built-ins, taking the set from nine to thirteen. The docs
table and description are updated to match.

* fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count
2026-08-18 00:08:31 +08:00