Each of these sits on an item the code does use: ssh_config's
import_profiles, merge_imported and jump_alias, SshConnection's key field
and key(), NativeSshSpec::without_secrets, CmdEditor::cursor, and
Pane::ssh_connection. Verified by removing every dead_code allow in the
workspace and reading back what the compiler then reported -- none of
these appeared, in either a production-only or an all-targets build.
A stale allow is worse than no allow. It reads as "this is dead, on
purpose", so the next person leaves it alone, and it goes on covering the
item after the code around it changes -- at which point the item really can
die without anyone hearing about it.
cursor_byte is the one that is genuinely unused outside tests, so it moves
to the house idiom #[cfg_attr(not(test), allow(dead_code))], which still
reports it if the tests stop using it too.
"the server answered a machine tree with {reply}" reads as though a machine
tree was the thing answered. It is the request that was for the machine
tree, and the reply is whatever came back instead.
The Chinese translator had already worked this out and supplied the missing
word -- "回复了机器树请求" says "replied to the machine-tree request" -- so
zh needed no change. English and Japanese carried the ambiguity.
"daemon" is the code's word for it -- the --daemon flag, daemon/server.rs,
the RemoteDaemon* keys -- and the UI deliberately uses "server": the
dialogs say "Restart Server?" and "Quit and Stop Server?", the settings
section is headed "Server", and the errors either side of the one fixed
here say "tty7's local server".
RemoteDaemonTooOld broke that in all three languages. It called the local
process a daemon while naming the far-end one a server in the same
sentence, so the two read as different components and the instruction
"Quit tty7 (which stops the daemon)" sent the user looking for something
the rest of the app never names. It now says "tty7's local server",
matching the two keys directly above it.
The ja settings heading said "デーモンサーバー" where en and zh both say
just "Server" -- mod.rs already documents that this heading is meant to be
that word on its own.
Add a test: no user-facing string may call it a daemon in any of the three
languages. The search-keyword lists are skipped, since they carry the word
on purpose to match what a user might type.
tty7 calls the split regions inside a tab panes and the docked regions
around them panels, and Chinese has a separate word for each -- 窗格 and
面板. Eleven strings across four keys used 面板 for a pane, so they named
a different part of the window rather than reading as a loose synonym.
The settings page showed both senses a few rows apart: "text size ...
tabs, panels and settings" is genuinely 面板, while "give each pane its
own shell history" right below it claimed panels have their own shell
history -- they have no shell at all. The proxy description, the stale
daemon notice and the restart-server body had the same swap.
Japanese was already consistent (ペイン / パネル), and the English is the
source, so only zh.rs changes.
Add a test over every key: when the English names one sense and not the
other, the translation may not carry the wrong word. It matches whole
words, since "panel" contains "pane".
The previous commit documented the `events` schema and listed `agent_status` as
one of the kinds a listener sees. It is not: `ControlEvent::AgentStatus` has the
same shape of problem as `PaneExited`, which is how it slipped through — the
only two references anywhere are the round-trip test in its own file and the
formatter in `tty7-cli`, with no emitter.
Auditing all six protocol enums is what turned it up. `ControlRequest` (42
variants), `ClientMsg` (30) and `DaemonMsg` (28) are clean; every `LayoutDelta`
and every other `ControlEvent` has a real emitter — `Preempted` and
`LayoutResync` in `host::server`, `GuiOpen` beside them — which is exactly what
made these two look supported.
Agent status is reported the same way a pane exit is: a `layout` delta whose
`pane_facts` carries the pane, with `agent.status` on it (`AgentFacts.status`
rides on `PaneRecord`). The note now covers both, and points at `tty7 wait` and
`tty7 agents` as the supported way to ask about an agent, since neither is
built on this stream.
Both variants now carry the comment; the recommendation is unchanged — emit
them or remove them together with their `event_line` arms, but not in passing,
because it is a dialect change.
2951 tests pass.
`events` is the streaming interface an agent builds on, and it was the one verb
whose JSON shape the reference did not give — every other one lists its fields
exactly. Now it does: the externally tagged envelope, the four event kinds a
listener sees, and the thirteen `layout` delta kinds.
The prose also listed "pane exits" as an event type. There is no such event.
`ControlEvent::PaneExited` is defined, encodes, and has a line
`tty7 events` would print for it — and nothing anywhere constructs one outside
the round-trip tests in its own file. `AgentStatus`, `Preempted` and
`LayoutResync` have 2, 10 and 6 emitters respectively; this one has none. An
agent waiting for a pane-exit line waits for good.
What actually happens is checked against a running server: exit the shell in a
pane and exactly one event arrives — a `layout` delta carrying `pane_facts` for
that pane with `"live": false`, and the pane is gone from `pane ls --all`. The
docs now say to watch for that, in a note that says outright there is no
pane-exit line whatever the wire protocol's shape suggests.
The variant keeps a comment saying the same thing, and that resolving it means
either emitting it where the pane is reaped or removing it along with the
`event_line` arm — not something to do in passing, since taking a variant out
of a wire enum is a dialect change.
2951 tests pass.
The warning on `pane close --orphans` said an orphan "can still be doing real
work — an interrupted `run` leaves the command running". True, and it stops one
step short of the case that costs someone work.
A `run` that is executing *right now* is an orphan too. `run --ws W` stamps its
pane with `W` as the owner and files it into no tab until `--keep` does, so for
the whole length of the command the pane reads exactly like a leftover.
Confirmed against a live server: `tty7 run --ws W -- sleep 45` shows up as
`orphan=true, owner=<W>`, the same shape a leaked pane has, and
`pane close --orphans` duly reported `closed 2 panes` — one of which was the
running command.
So the old advice — "look at `pane ls --all` first" — cannot be followed:
nothing in that listing tells the two apart. The docs now say that, and say
what to do instead (close by id when anything might be running).
This also rules out the reaper people will reach for when they meet the pane
leak in `ui::tree_sync`: the daemon's own `spawn_orphan_sweep` already computes
this exact set and deliberately only reports it, and an in-flight `run` is why
acting on it would be wrong. `orphan_panes` now carries that reasoning.
The distinction that would work is whether a client is still attached — an
interrupted `run` has none, a running one does. The daemon knows and
`PaneInfo` does not say; adding the field is backward compatible, since every
other field on it is already `#[serde(default)]`, but it is a wire change and
wants more than a doc pass.
2951 tests pass.
The daemon answers a pane it is not running exactly as it answers an idle one:
`registry.get(pane_id)` misses and the reply is an empty `PaneProcs`. So
tty7 procs %999
printed `nothing running in this pane` and exited 0 for a pane that has never
existed. Every other verb taking a `%PANE` says when the pane is not there, and
an agent reading this one could not tell the two apart — which is the whole
point of the machine-readable half.
Checked against the registry rather than the workspace tree, because a pane no
workspace holds is still a pane the server runs and still worth reporting on;
that is exactly what `pane ls --all` surfaces it for. Verified live: a real
pane and an orphaned pane both still answer with exit 0, and %999 now exits 1
with "no pane %999 on this machine — `tty7 pane ls --all` lists them".
Asked only when the answer came back empty, so a pane with anything running in
it still costs one request.
One existing test needed the mock's registry seeded alongside its machine tree.
That is the fixture becoming faithful rather than the check being loosened: a
server running the pane its tree names is what the real pair look like, and the
mock had the tree without the registry.
2951 tests pass.
With no daemon running, most verbs give the message that helps:
tty7 ls could not reach the tty7 server on this machine —
`tty7 server start` brings one up
The three that go to a pane instead of to the control socket did not:
tty7 send %1 hi sending input to pane %1: No such file or directory (os error 2)
tty7 capture %1 observing pane %1: No such file or directory (os error 2)
tty7 procs %1 No such file or directory (os error 2)
All three read as if the *pane* were missing, or some file — and `procs` gave
no context at all. Panes are reached over the daemon's own socket, and with no
daemon there is no socket, so the connect fails with a bare `NotFound` that
each caller then wrapped in its own words.
`local_control` has contexted its connect all along; this is the same courtesy
one socket over. The local pane client is probed once when it is built, so all
three now say what `ls` says.
Local only, deliberately: the message names *this* machine, and a routed client
would pay a round trip across the link to be told something untrue of the far
end. Cached with the client, so it costs one round trip per process.
Nothing real is swallowed — a daemon that is running answers a bad pane id with
its own message, and still does:
send %999 sending input to pane %999: no such pane 999
capture %999 observing pane %999: daemon refused Observe: no such pane 999
Checked against a live instance both ways, plus a full send/capture round trip
on a real pane.
2949 tests pass.
`serve_sigterm` blocks SIGTERM and waits for it on a dedicated thread, so that
`store_scrollback_now` can write every pane's screen one last time on the way
out — "the periodic writer covers the deaths nobody gets to prepare for; this
covers the ones we do".
It ran too late to do that. `pthread_sigmask` blocks a signal on the *calling*
thread only, and a signal sent to a process is delivered to any one thread that
has not blocked it. The call sat after the control listener was already
serving, so several threads had SIGTERM unblocked and the kernel could hand it
to one of them, where the default disposition ends the process on the spot:
`sigwait` never returns, no screen is written, and nothing is logged.
Measured against a running daemon, sending SIGTERM directly:
before the marker printed after the last periodic snapshot was absent from
the store, and "daemon shutting down on SIGTERM" never appeared
after the marker is in the store and the line is logged
So a machine shutting down, a logout, a supervisor stopping the daemon or a
plain `kill` cost every pane up to SNAPSHOT_INTERVAL — 30 seconds — of screen
that the code was written to keep.
The fix is where the call sits, not what it does: immediately after the
registry exists and before the control listener, which is the first thing in
`run_daemon` to start a thread. A thread inherits the mask of the one that
created it, so blocking there makes the whole process deaf to SIGTERM except on
the waiter. Blocking and waiting stay in the same call, so there is never a
window where the signal is blocked with nobody to answer it.
`tty7 server stop` was never affected: it asks over the protocol
(`ClientMsg::Shutdown`) and only falls back to signals, so it took the other
`store_scrollback_now` path.
2949 tests pass.
`Config::load` runs on every pane spawn and every palette command, and the
parse-failure arm logged a warning each time. One stray comma in config.json
therefore produced a log made of the same line: measured at 23 copies after
opening three tabs and sending one command, growing for as long as the session
lasts and burying every other warning in the file.
The answer was already there. `quarantine` refuses to keep a second copy of
contents it has kept before — that check is what stops a broken config filling
the directory with identical `.corrupt` files — so it already knows whether a
breakage is newly seen. It now says so, and the parse message is a warning the
first time and a debug line afterwards.
Nothing else moves: the file is still kept aside, `save` still refuses to run
against a quarantined config, and the user's file is untouched.
Verified against a running instance with a deliberately malformed config.json:
the same four operations that produced 23 warnings now produce 1, there is
still exactly one `.corrupt` file, and config.json is byte-identical to what
was written before the app started.
2949 tests pass.
The note said refused tree operations are how this window strands a shell.
They are one way, and the minority one — which matters, because it is the way
that would attract a fix.
Measured with a randomized tree fuzz over the CLI (70 operations: tab new,
split, pane close, tab close, tab move, invariants checked after each):
daemon only, no GUI 0 orphaned shells
same run, window open 16 orphaned shells, 12 panes actually held either way
A second run of 70 left 10 orphans, and the warning this note sits on fired for
3 of them. The other 7 were spawned for a layout the window then threw away
without sending an operation at all — the log shows "dropping a superseded
hydration" and a delta that "did not apply cleanly" forcing a re-pull. Nothing
is refused on those paths, so nothing reaches this arm.
They are live `zsh` processes, not bookkeeping: each orphan answers
`tty7 procs` with a pid.
So the note now says which share this path accounts for, and that the sweep has
to be phrased against the end state — a pane this window spawned that neither
the machine tree nor any live view holds is stranded however it got there —
rather than against any single failure, which would fix a third of it.
Still diagnosis only, for the reason already recorded: the sweep belongs after
a hydration settles, and verifying it needs a GUI this environment cannot
watch.
2949 tests pass.
Last commit hedged on whether the missing russh fixes matter to a client,
because the release notes describe them as a client crashing its own session.
Matching the whole lockfile against the GitHub advisory database settles it —
the advisory titles say plainly what the release notes did not:
GHSA-5xvq-cp9x-6p6r "Pre-auth remote panic via all-zero Curve25519 peer
public value (encode_mpint OOB)"
GHSA-g9hv-x236-4qp3 "client wrong-length X25519 clone_from_slice panic
(pre-auth DoS)"
tty7 is the client, the peer is whatever server a profile dials, and this
lands before authentication — so anything sitting in front of that server
reaches it too. Both are patched in 0.62.4; this rev is from 2026-07-14 and
predates it.
The other three are named too, with the two server-side ones marked as such
since tty7 runs no SSH server.
Still documentation only, for the reason the previous commit gives: moving the
SSH layer wants an SSH connection to test against, and connecting needs the GUI
connection manager, which this environment cannot drive. The fix is unchanged —
rebase the fork onto v0.62.6+, and drop the patch once #738 ships in a release.
2937 tests pass.
The patch note said "temporary until upstream releases gssapi-with-mic
support (PR #737)" and left it there. Checking that exit condition turned up
three things worth writing down.
The PR number is wrong: #737 does not exist. The real one is Eugeny/russh#738,
and it was merged upstream on 2026-08-11T09:27:51Z — so the condition this
patch was waiting for has half arrived. Only half: v0.62.6 was published at
09:26:56Z the same day, one minute before the merge, so no release carries it
yet and the patch still has to stay.
The part that matters more: this rev is dated 2026-07-14 and branched before
v0.62.3, so it is missing every security fix upstream has published since —
v0.62.4's three (malformed PTY request, malformed Curve25519 KEX packet, zero
Curve25519 key), v0.62.5's channel-ID validation and v0.62.6's
max_auth_attempts. The last two are described as server-side and tty7 runs no
SSH server, but "fix mpint encoding and validate curve25519 keys" is in key
exchange, which a client runs too, against whatever server it dials. Flagged
for assessment rather than asserted: I have not reproduced it.
No dependency change here. Moving the SSH layer wants an SSH connection to
test it against, and connecting needs the GUI's connection manager, which
cannot be driven from this environment. The interim fix is to rebase the fork
onto v0.62.6 or later; the real one is to drop the patch when #738 ships.
Verified with the GitHub API rather than assumed: commit dates on both sides,
and the fork does not contain any of the four fix commits.
2937 tests pass.
tty7 has a `ClipboardLoad` arm that reads the system clipboard and writes it
back down the PTY, and nothing in this crate says that must not happen. It does
not happen — `alacritty_terminal` refuses a paste request before it becomes an
event, because `Config::osc52` defaults to `OnlyCopy` — but that is the VT
crate's decision, taken by a field tty7 never sets, protecting a handler tty7
has already written.
So the exposure is one upstream default away: a release that moved `osc52` to
`CopyPaste` would hand any program able to write to a pane whatever is on the
clipboard, with no prompt and nothing in the sequence identifying who asked.
The program can be on the far end of an SSH connection, and a clipboard
routinely holds a password, a token or a private key. Terminals that offer the
read at all put it behind a prompt or an explicit opt-in.
`osc52: Osc52::OnlyCopy` is now stated where the rest of the terminal config
is, so writes still work and reads stay refused by tty7's own decision. No
behaviour change today. Same shape as `conpty_resize` two lines up, and it gets
the same kind of test, for the reason that one documents: a field whose default
already matches is a field whose line can vanish unnoticed.
The `ClipboardLoad` arm keeps a note saying what makes it unreachable and that
unlocking it needs a consent step first.
2937 tests pass.
A kitty graphics escape is untrusted input — any program running in a pane can
print one — and every bound on that path is deliberate and spelled out, except
this one. `to_rgba8` clamps an inflate to `min(declared, MAX_IMAGE_BYTES)` and
says why the declared size cannot be trusted on its own; the PNG branch called
`image::load_from_memory`, which leaves the ceiling to `Limits::default()`.
That default is 512 MiB — a real bound, eight times `MAX_IMAGE_BYTES`, and
somebody else's number. A patch release that relaxed it would silently widen
what a hostile escape sequence can allocate, and this runs in the GUI process,
so an OOM here takes every pane down rather than the pane that asked for the
image. The limit is now stated locally at the value the default has today, so
behaviour is unchanged and the invariant stops depending on a dependency's
opinion.
Why the number is not simply `MAX_IMAGE_BYTES`: that bound comes from what a
wire frame carries, and a PNG crosses the frame still compressed, so its
decoded size is the one thing on this path not already settled. Lowering it
would bound this properly but decides how large an image tty7 means to display,
which is a product call and not a cleanup — noted in the comment.
The PNG branch had no test at all, so the change is covered both ways: a real
2x1 PNG decodes at its own dimensions rather than the escape's `s=`/`v=`, and
a 1x1 PNG whose IHDR is rewritten to 0xffff x 0xffff (~17 GB, CRC repaired so
the decoder parses it) is refused. Checked out of band that the refusal really
is "Memory limit exceeded" and not a malformed-file rejection, so the test
cannot pass for the wrong reason.
2936 tests pass.
`StdioDuplex::take` dup'd stdin and stdout, redirected both to /dev/null, and
only then wrapped the raw descriptors in `File`. Any of the four `?`s between
the first `dup` and the wrap returned without closing what it already held —
a descriptor leak on the error path.
`dup_fd` now hands back an `OwnedFd`. `dup` returns a fresh descriptor nothing
else holds, which is exactly that type's contract, and ownership from the
moment it exists is what makes the early returns safe. `take` drops two
`unsafe` blocks in the process: `File::from(OwnedFd)` is the safe conversion.
Immaterial in production — `take` is called once in tty7-server's main and the
`?` there exits the process — but it is unsafe-adjacent code where the correct
version is also the shorter one.
Exercised by the 55 `stdio_conformance` tests, which drive
`tty7-server --stdio` through this constructor. 2934 pass.
The table synthesized the `local` row itself while `--json` serialized the
server's routes alone, and a route is a link to some *other* machine. So on a
machine with no remotes — which is every machine until someone connects one —
`tty7 machine ls` printed a row for itself and `tty7 machine ls --json`
answered `{"machines":[]}`. An agent enumerating machines read that as "there
are none", including the one it was running on.
The docs already said what it should be: "the local machine plus every link",
with `{"machines":[{"key","kind","connected"}]}`.
The list is now assembled once in `machine_ls` and handed to both renderings,
so the two cannot disagree again; `routes_table` renders what it is given
instead of adding a machine to it. Pinned by a test that asserts the table and
the JSON agree, with and without a link.
Found by running the command against a dev instance rather than by reading it —
the human output looked right, and it was the half nothing prints by default
that was wrong.
2934 tests pass.
`TabCreate`, `PaneSplit` and `PaneReplace` each carry a pane the window has
already spawned — the shell is running on the machine before the tree is told
about it. When the tree refuses one of those, nothing ever takes the pane and
the re-pull leaves the window without the tab it was for, so the shell goes on
running with nothing referencing it. The log said only that an operation was
refused, which is the one thing that does not point at the leak.
Reproducible against a running instance: with a window open on a workspace,
`tty7 tab new` and `tty7 tab close` back to back. The GUI restores the new tab,
finds its pane already hung up, spawns a replacement, and `PaneReplace` is
refused because the tab is gone. Four cycles in five leak a shell; leave half a
second between the two and none do. Killing the GUI and repeating the loop
leaks nothing — the CLI half is correct throughout.
Not swept here, and the comment says why: at the point of the refusal the
window still holds a view for the pane and only drops it once the re-pull
lands, so hanging it up here would kill a pane that is still on screen. The
sweep belongs after the pull settles and has to test the whole machine tree
rather than this workspace's mirror, since a pane belonging to another
workspace on the same host is not this window's to end. That change wants a
GUI it can be watched in; this one only makes the leak say its own name, and
`tty7 pane ls --all` already points at the recovery.
`seeded_pane` is pinned by a test, because the set of requests that name a pane
into existence is exactly the set a refusal can strand one from — a new one
added without it would leak silently.
2933 tests pass.
Found by driving a dev instance with the CLI rather than by reading, which is
the only way any of these would have surfaced.
`ws attach` is documented as "become its controlling client", and the CLI
cannot be one: the claim belongs to the connection that made it and the
connection ends when the command does, so `tty7 ls` reads unattached again the
moment it returns. Its real and only lasting effect is displacing whoever held
it — which is exactly what the human output says (`took over from HOST`) and
what the docs never did.
`ATTACHED` said it names "a GUI window, or another client". A GUI only claims a
workspace it is showing as a *remote* one; a window on a workspace of its own
machine claims nothing, because there is no second client to arbitrate against.
So with a window sitting on it `tty7 ls` prints `-`, and the column read as
"nothing has this open" when it means "no remote client holds this".
`tab move @TAB INDEX` never said which end `INDEX` counts from. It is 0-based —
beside an `@N` address that is 1-based, on the same command line — so
`tab move @1 2` moves the first tab to the third slot. Verified against a
running server: `@1 0` leaves it, `@1 1` puts it second, past the end clamps to
last the way `split --ratio` already documents. `to` in the JSON echoes the
number you asked for rather than where the tab landed, which is worth saying
since the clamp makes those differ.
Also records why `WorkspaceDetach` throws away the one thing it computes: the
reply has always been `Unit`, and the dialect is spoken to whatever build was
pushed to a remote machine, so widening it to `Bool` would break every server
already out there.
2932 tests pass.
Nothing had ever run `cargo doc`, so 16 warnings had collected. Four were
links to items that do not exist, and two of those were worse than a dead
link: `git_badge` and `info_chip` documented their sizes in terms of
`PANEL_TEXT` and `PANEL_TEXT_META`, px constants deleted when the interface
font scale landed. The module comment twenty lines up already says they went;
the prose downstream still derived pixel arithmetic from them, so a reader was
being told the pill is 20px tall against a 19px neighbour when both are now
rems that move with `ui_font_size`.
Rewritten against the ladder that exists (`META_MONO` beside `TEXT_MONO`), and
`info_chip`'s comment now records what its own numbers imply: its padding and
radius are pixels wrapped around rem-sized text, so the two stop agreeing once
the interface scale leaves 100% — the same trap `PIP_SIZE` right below it is
written in rems to avoid. Left as a note rather than changed, because that is a
visual decision and this cannot see the result.
The other ten are `private_intra_doc_links`, and that lint does not apply here:
it exists so a *published* crate does not ship docs whose links dead-end, and
all four crates are `publish = false`. Allowed at the crate root with that
reason, because a public item explaining how it relates to a private one is the
useful half of these comments.
The `clippy` job becomes `lint` and runs rustdoc too, on the same warm cache.
Still non-required.
2932 tests pass.
`theme_preset` replaced `theme` on 2026-07-17, but the field stayed on the
struct, so every save since has written a `"theme": "light"` into the user's
config.json that nothing reads. It has no doc comment — the only field in the
file without one — no migration reads it, and grepping for it finds only the
`ui::theme` module.
Removing it is backward compatible in the direction that matters: `Config`
takes no `deny_unknown_fields`, so a file still carrying the key loads and the
key simply stops being written back. There is nothing to migrate; by the time
it was removed no build in the wild consulted it, which is also why a config
upgraded from before July never had its theme carried across.
Pinned by a test, because "the old file still loads" is the whole claim.
Found by diffing the documented configuration keys against the struct: it was
one of three fields with no entry in docs/reference/configuration.mdx, and the
other two (`command_frecency`, `ssh_profile_frecency`) are usage counters
rather than settings and are right to be undocumented.
2932 tests pass.
`closing_the_pool_drops_queued_work` never tested a queued job. `submit`
grows the pool whenever `jobs.len() > idle`, so blocking one worker and
submitting a second job spawns a second worker and runs it immediately —
the assertion only held when `close()` beat a brand-new thread to the state
lock. On a loaded machine it lost that race about one run in six.
A job only stays queued when the pool is saturated, so the test now fills it
to MAX_WORKERS first, asserts the job is on the queue before closing, and
waits for the workers to drain afterwards. 14 consecutive `-p tty7-core --lib`
runs green, against 2 failures in 16 before.
Placeholders in a translation were checked by nobody: `apply_template`
substitutes by name and leaves anything it was not given alone, so a `{name}`
dropped from the zh string reaches the user as a sentence that has simply
stopped naming the host, and an invented one draws braces on screen. Neither
is a missing or empty translation, which is all the parity test could see.
Both are now checked, for the plural and select branches too. Nothing was
wrong today.
The `KEPT_IN_ENGLISH` staleness check printed rather than asserted, and cargo
swallows a passing test's stdout, so the list could only ever grow.
The Linux build dependencies existed in four copies across three workflows;
release and nightly had the AppImage packaging tools (libfuse2, file,
imagemagick) mixed into the same list, which reads as if they were needed to
build. One script now, with the packaging extras installed separately, and the
ci.yml comment points at the docs page that actually documents the list rather
than at the README, which never did.
2931 tests pass.
CI checked `cargo fmt --check` and the build, so nothing ever read the
content of the code — ~200 clippy findings had accumulated, a third of them
in `#[cfg(test)]` modules.
Two of them were real:
- `resolved_background_appearance` took `backdrop`, and only the
`#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy
asks for and compiles cleanly on macOS; on Windows it is an undefined
name. Kept the parameter and discharged it in the non-Windows arm the way
`package_for_current_install` already does.
- `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were
carrying translations in three languages for strings nothing reads.
The rest is mechanical: let-chains for collapsible `if let`s, struct-update
syntax for `Default::default()` reassignment, `sort_by_key` where a manual
reversed comparator was doing the same job the same file already did with
`Reverse` two functions later.
Where clippy was wrong, the reason is now in the tree rather than rediscovered:
per-platform `#[cfg]` blocks each keep their `return` (dropping it only
compiles on whichever target's block lands last), the loopback parser keeps
three parallel arms instead of folding one into a `?`, and the four wide enums
are all built on the stack and consumed immediately, so boxing them would add
an allocation rather than save one.
`L10nKey` cannot be clean under `dead_code` on any single platform, so the
allow there records how to audit it instead — which is how the eight dead keys
were found.
`english()` gained the cross-platform test its doc comment already claimed;
only the Windows hint had been pinned.
The clippy job is non-required until it has green history, for the same
branch-protection reason `host-boundary` documents.
2930 tests pass.
* feat(tabs): drag a tab in as a pane, and a pane out as a tab
A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.
The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.
Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.
Two things in the machine tree had to follow:
* Panes that change tabs are told as PaneMove, one at a time, rather
than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
created, so a pane leaving for a tab of its own is given up by the
old tab before the new one asks to register it. The machine refuses a
pane that is in two tabs at once, and the refusal desynced the window.
Closes#621
* test(tree-sync): a tab grafted above a whole layout still converges
* fix(tabs): keep a click on the close button from switching tabs
Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.
Both now hold the click where they handled it, the way they already held
the press.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(scm): keep the sync tile on the branch row at any panel width
The branch row's flex constraints were set on the Button, but
`dropdown_menu_with_anchor` hands that Button to a `Popover`, which wraps
it in a plain div and never applies the trigger style it was given
(`trigger_style` is stored and never read). The constraints landed inside
a box that still measured its own content, so at the panel's 216px floor
a 24-character branch name overflowed the row and pushed the sync tile
out of the panel entirely, with no way to reach it.
Carry `flex_1` on a wrapper instead, and truncate the name against the
width it is actually given rather than against a character budget that
was guessing at that width. The `elide_middle` ceiling is gone: stacked
on top of a real truncation it produced two ellipses in a row
(`fix/new-tab-……`) and threw away the tail it existed to keep.
Notes and chips move into one `overflow_hidden` group that is allowed to
shrink, so the order of who gives way is explicit: branch name first,
badges second, the tile never.
Also stop offering "Publish Branch" from a HEAD that cannot publish. A
detached or unborn HEAD has no upstream by definition, so the token fired
there unconditionally — the widest thing on the row, naming the one
operation the tile beside it already refuses (#545), and on its own
enough to push that tile off a 216px panel.
* fix(scm): drop the branch row's note box when it holds nothing
The row lays the notes and chips out in one shrinkable box so the sync
tile keeps its place. An empty box is still a flex item, so the row's
6px gap was spent on either side of nothing: on the quiet branch that
is most of what anyone looks at, the caret sat 12px off the tile
instead of 6px. Build the notes first and only add the box when there
is something in it.
Also drop two comment citations of #549, which is about palette
commands that no-op silently and has nothing to do with this row.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(new-tab): keep the SSH menu inside a menu's shape
The saved-host rows carried names and endpoints long enough to drag the panel
out to the 500px ceiling PopupMenu falls back to, and the row that meant to
elide was clipped mid glyph instead. The menu now stops at 360px, and a row
that runs out of room cuts the endpoint first — the name is what the reader is
picking by, so it keeps whatever is left rather than being squeezed to "..".
The height ceiling moves up to fit the shape everyone actually sees — nine
shells, both headings, six hosts and the two closing rows — so the default menu
arrives whole instead of scrolled with "Local" cut off above, and is capped
again against the window so a short one never gets a menu taller than itself.
The rule above the split hint goes: a separator divides two lists of things to
pick, and the hint is a footnote about the list it follows.
Bumps gpui-component, where a scrollable PopupMenu painted a scrollbar whether
or not it overflowed, custom rows could not elide, and labels had no padding of
their own.
* fix(new-tab): measure the menu ceiling off the viewport, and elide nameless hosts
`window_bounds()` answers how a window should be reopened after it is
closed, so a fullscreen macOS window reports the bounds it would restore
to rather than the screen it currently fills. A terminal spends much of
its life fullscreen, where that reading capped the menu at 80% of a
window nobody is looking at — putting back the scrollbar and the
cut-off `Local` this branch is here to remove. `viewport_size()` is what
every other window-relative size in the app already measures against.
A host saved on its address alone is *named* `user@host:port` and carries
no note, so it took the plain-item path — bare text with nothing to elide
against, on the longest string in the menu and the row least able to cut
it. Every host row is a custom element now, and `menu_row` drops its
right half when the note is empty rather than holding the gap open with
a zero-width child.
Also drops 17 unrelated dependency downgrades that rode along with the
`gpui-component` bump. The lockfile moves only the three `source` lines
it meant to; `cargo check --locked` accepts it.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Adds a saved-SSH-hosts section to the New Tab menu on both the tab strip and the sidebar, ordered by frecency, with a row that opens the full host palette. Holding the modifier opens the host in a split instead of a tab.
The menu scrolls once the rows outgrow the popup, long host names and their endpoints truncate rather than overflow, a host with no name of its own draws its endpoint once instead of twice, and the rows are built when the menu opens rather than on every painted frame.
Hoists the SSH password prompt's overlay from the body area to the window root so its scrim covers the title bar, tab strip and side panels, and aligns its top offset with the switcher card.
Replaces the raw target string with the resolved machine label on the reconnect banner and on the connecting pane, so a profile-backed machine no longer shows a bare config UUID in "Connecting to …" or "Could not reach …".
Bumps the gpui-component pin to pick up the notification restyle: flow-positioned status icon and close button that centre on the first line at any wrap count, a hairline-shadow surface in light theme, and a type ranking expressed in rems so it survives the ui_font_size setting.
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files
Two problems shared a root: the daemon outlived every window, and
nothing could stop it gracefully.
Window lifecycle:
- Closing the last window retires the app to the tray instead of
quitting (QuitMode::Explicit), so the daemon stays reachable. The
tray restores the most recent workspace, and Quit — after the
confirmation that protects running shells — stops the daemon. Every
explicit exit path (tray, palette, keybinding) now stops the server;
no exit leaves an orphaned daemon behind a dead icon.
- A pathless launch (double-click) hands off to the registered GUI via
GuiOpen(None) and exits, instead of starting a second process with a
second tray icon.
- The tray subsystem initializes once per process; reopening a window
no longer creates a duplicate icon.
Cold-start robustness:
- Liveness connects are bounded to 500 ms, the version handshake times
out in 1 s, and an unresponsive daemon is reaped by its recorded pid
instead of polled for a 6 s graceful stop.
- A dead recorded pid skips the TCP probes entirely — the GUI's
ensure_running, the new daemon's endpoint check, and the
control-listener occupancy check (which could also misread a reused
port as a live control server and refuse to boot). Stale cleanup now
also removes the leftover control.port.
* fix(daemon,gui): skip the GuiOpen handoff probe when the recorded daemon is dead
* fix(lifecycle): keep the stale-endpoint cleanup, and do not retire into a tray that is not there
Three gaps in the tray-persist and cold-start work.
`ensure_running` moved the refused-connect branch under the new liveness
check, so a connect that fails while `recorded_daemon_is_dead` says "not
dead" now skips the reap and the stale-endpoint removal entirely. The
pidfile answers "not dead" to two cases it has no evidence about: it is
missing (the daemon died between `transport::bind`, which writes
daemon.port, and `pidfile::write_current`), or it records a pid the OS has
since reused. Both then leave daemon.port on disk and the spawn poll pays
the OS's refusal delay on it — the cost this path was rewritten to avoid.
Restore the branch, and split the rule into `recorded_daemon_is_dead_with`
so a test can state that a missing pidfile is not evidence of death,
without an env var every parallel test would inherit.
The tray's windowless Quit stopped the server without a prompt, reasoning
that the confirmation is about the panes behind a window. It is not: it
says "anything still running in your shells is terminated", and retiring to
the tray is precisely what leaves those shells running with no window. Bring
the window back and deliver the action to it, so the confirmation appears;
only when no window can be opened does the bare stop remain, with a warning.
`show_tray_icon` is a request, not an outcome. `Backend::create` can fail
for a whole run — a Linux session with no StatusNotifier host is the
ordinary case — and after MAX_ATTEMPTS the loop gives up and logs. Retiring
on the config alone then leaves a process with no window and no icon: not
reachable, and still holding the daemon. Gate the retirement on an icon
actually being up.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks
feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks
Nushell was the only detected shell with no integration: shell_kind
never matched `nu`, so panes were spawned with no OSC 7 cwd reports
and the daemon could not follow `cd` — pwsh, zsh, bash and fish all
do. Recent Nushells emit their own OSC 133 marks and a Windows
`OSC 9;9` cwd protocol, but tty7 only consumes OSC 7, and the native
`shell_integration.osc7` toggle defaults to off.
The injection rides `nu --config` (Nushell has no ZDOTDIR analogue): a
throwaway config.nu sources the user's own config.nu back in first,
then appends hooks. `source` is a parse-time construct in Nushell — it
cannot be guarded at runtime or name a missing file — so the Rust side
resolves the path with the same rules `$nu.default-config-dir` uses
(APPDATA / XDG / HOME) and substitutes a literal, or a no-op line when
there is no config.nu. The hooks report cwd (OSC 7, %-escaped, `/C:/…`
shape on Windows), prompt start (A) and the previous command's exit
(D, gated on a flag the pre_execution hook arms so the first prompt
emits nothing), mark command output (C), and wrap prompt_indicator for
the prompt-end mark (B) only when the config defines one — recent
Nushells' built-in prompt keeps drawing its own indicator and B mark,
and overlapping marks merge in the daemon's prompt-state machine.
Remote SSH and WSL panes are unchanged: their bootstraps cannot carry
a Nushell config, and a nu-as-login-shell session would break on one.
Tests: static assertions on the script, setup dispatch, literal
substitution, and a Windows real-PTY cycle asserting OSC 7 follows
`cd` and every mark arrives with the correct exit status.
fmt
* fix(shell): resolve the Nushell config dir the way nu does
The wrapper's `--config` replaces the user's config.nu entirely, so the
path it sources back must be the one nu itself would load — anything
else silently strips macOS panes of their prompt, aliases and
keybindings. `dirs::config_dir()` is `~/Library/Application Support`
on macOS, not `~/.config`, and nu-path consults `$XDG_CONFIG_HOME` on
every platform (Windows included) but only when it is non-empty and
absolute. The resolution now mirrors that: a per-platform default plus
XDG winning only in the exact shape nu accepts.
Also gate the OSC 7 backslash translation on Windows
(`$nu.os-info.name`) so a Unix path with a literal backslash survives,
and harden the real-PTY tests: one line per submitted command
(reedline drops input while a command runs) and a grace period after
the D mark so the cwd report that follows it in the same prompt cycle
lands in the transcript.
The tab title follows the terminal's OSC title, and nearly every prompt
framework sets that to the command it is about to run and puts the old
title back at the next prompt. For anything that finishes in a blink both
edges arrive within a few frames, so the label showed the command and
snapped straight back — a flicker that reads as a rendering glitch rather
than as information.
Hold a new title for 400ms before the tab adopts it. A title that reverts
inside the wait matches what the tab already shows and drops the pending
one, so a short command never reaches the label at all; one still running
when the wait elapses names the tab as before, 400ms later.
A second title arriving mid-wait rides the wait already in flight instead
of restarting it. Restarting is what would let a program that rewrites
its own title faster than the wait — a download reporting progress —
put the tab's next update off for as long as it ran.
Child exit clears the pending title and writes its own immediately: a
title still waiting its turn would otherwise land on top of "(process
exited)" a moment later.
The centring added in #631 turned the settings content box into a flex
column, and that cost the page most of its scroll range: the box is an
item of the scroll pane, which is itself a flex column, so its height
came out of a negotiation with the pane rather than from the rows it
stacks. `content_size` is just that box's laid-out bounds, so the range
ended a screen short of the last row — dragging to the bottom still left
content cut off. `flex_shrink_0` does not help; the height is agreed,
not squeezed. Centre with `mx_auto` on the column instead and leave the
box a block, which reports the full height it stacks.
While there, hold the content scrollbar 12px clear of the top and
bottom. Every other list this bar serves sits in a bordered panel where
running the full height is right; this pane is the window, and a bar
drawn to the last pixel lands on the rounded corner. New
`with_inset_vertical_scrollbar` takes the inset, and the existing
`with_vertical_scrollbar` keeps its behaviour for the other twelve
call sites.
* fix(remote): stop the server on machines that have no /proc, and show the install on the strip
Restarting the remote server timed out after ten seconds on every Mac and
BSD, with the old daemon still running and the new binary already sitting
next to it, unlaunched.
Both the probe that finds the running `tty7-server-*` and the command that
terminates it walked `/proc/[0-9]*` and read each `exe` symlink. There is no
`/proc` there. Two things then went wrong at once. zsh is the login shell on
macOS, and it aborts the whole command line when a glob matches nothing, so
even the trailing `true` never ran; and `cycle_daemon` discards the result of
the terminate, so a command that killed nothing was indistinguishable from
one that worked. `daemon_is_serving` then answered yes until the deadline.
Guard the glob behind `[ -d /proc ]` — unreached, it is never expanded, so
zsh has nothing to abort on — and fall back to `ps`, whose `comm` is the full
path on the BSDs. It cannot be the only branch: Linux truncates `comm` to 15
characters, one short of `tty7-server-c7p5`, which is why `/proc` stays the
first choice where it exists. `check_running_build` reads the same probe and
was equally blind on those machines; it can see now.
Separately, the install progress bar only ever existed inside the switcher.
Pressing Update Server from a parked workspace with no switcher open froze
the window for the length of the download and then produced a modal, with
nothing in between. The strip draws it too now — caption and bar from the
same source the switcher uses, and no button while an install is in flight,
since pressing it again would start a second one on top of the first.
* fix(remote): say why a stop failed, and stop a leaked install from eating the strip's button
Three things the no-/proc fix left standing.
`cycle_daemon` still discarded the terminate's result, which is the other half
of why a Mac cost a bug report: the command ends in `true`, so anything short
of success means the far end never reached the kill at all, and that is exactly
what a zsh abort looks like. It is now logged, and named in the timeout error —
"the running remote daemon did not stop within 10s" on its own blames a daemon
for ignoring a request nobody managed to send it.
The strip hides its Update Server button whenever an install is in flight,
which is right, but it reads the progress registry with no link state to temper
it — unlike the switcher. `finish_connect` bows out before clearing that entry
whenever `connect` has moved on in the meantime, and a switcher disconnect or a
move to another workspace both do that mid-install. The leftover froze a
progress bar on every window pointed at the machine and took away the one
button that could have fixed it. Cleared where the attempt actually ends
instead, however it ended.
The switcher kept its own copy of the progress bar after the caption was
shared; it draws the shared one now.
Tests: the probe runs for real in every shell on the machine rather than only
parsing under `sh -n` — the glob that started this was valid syntax and only
fell over when zsh ran it, which no `-n` can see. `ps` is checked on its own
where the fallback would actually be taken, since that arm eats its own stderr
and a rejected flag would otherwise cost nothing visible. And a stop that fails
is asserted to reach the error.
`with_shutdown_timeout` exists so that last test does not sit out ten seconds.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
A pane that never armed shell integration blamed a PTY wrapper or an unsupported shell setup. Since #629 a zsh or fish the user gave arguments to is deliberately left alone, so the notice now names that first — it is the one cause the user can undo. All three locales.
The release notes gained the matching entry: the change turns integration off for an existing config that sets `shell` or a `custom_shells` entry with `args`, which is worth stating outright.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
A window's mirror of its machine held no `PaneRecord` for a pane the window itself created. Records ride inside layout deltas and a client is left out of the deltas its own ops raise, so the record the daemon mints when it registers a seed reached every window but the one showing the pane; `PaneFacts` closed the gap only when a fact changed, and a pane spawned into its directory and left at its prompt never changes one. The workspace answered no subject path, an unnamed one read "Untitled", and `record_geometry` stamped a null subject over the path views.json remembered.
The window knows what it seeded, so it now puts those records into its own mirror through the same `PaneSeed::into_record` the daemon mints with — opened up rather than duplicated, so ssh-secret stripping stays shared — with the window's own Ready terminals standing in for the daemon's liveness probe. The write is insert-only: a record the mirror already holds came from the machine and outranks what a seed knows.
Also closes the race that reopened the same symptom by another route: a `MachineGet` already in flight installed its tree whole and took the client's not-yet-acknowledged writes with it, and nothing re-inserted them until the next non-empty op. All four optimistic writers now go through one path that keeps each write for the life of a pull in flight and replays it over the tree that lands. The tree stays authoritative for everything it speaks about — only the ops it was built too early to know are put back on top, and the journal is drained once it has landed, so nothing a later tree dropped is resurrected. This covers #604's pushed tabs and workspace ops, not only the seeded records.
Fixes#612.
Since #415 the daemon echoes a `Size` frame at the stream position where the pty changes geometry and the client defers its grid reflow to that marker — but only on local routes, so a remote pane resized mid-flood still parsed queued old-width bytes into the new-width grid, which network transport makes worse.
Rather than probing `Version` per pane (a whole routed connection, and for ssh/WSL a whole bridge process, on every spawn and attach), the server advertises the pane protocol's features on its control hello. The answer is cached on the link and read off the host when a pane's route is built, and the route carries it to the terminal at spawn, attach and relink. This is additive within `CONTROL_VERSION` 7: no new field, just extra names in the existing `ControlHelloOk.features`, so an older client cannot choke and an older server that names no echo makes the client reflow at request time as before. A route built while the link is down answers false.
Known limitation, inherited from #415's design and not introduced here: there is no timeout if a promised echo never arrives — once deferred, a later identical resize neither re-sends nor reflows, so a wrongly-set bit would freeze the grid at the old geometry. Every traced path makes the control hello and the pane daemon the same build, normally the same process.
Closes#416.
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.
The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.
Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.
Closes#620.
The zsh and fish arms of `shell_integration::setup` never checked `has_custom_args`, so a shell the user launched with their own arguments was injected anyway — fish had `-C <script>` appended to its argv, zsh had its ZDOTDIR swapped. Both arms now sit behind the same gate bash, PowerShell and WSL already used, hoisted to a single early return ahead of the dispatch so a new ShellKind cannot silently reintroduce the bug.
Docs now describe what the code does: the `shell` row's own `{"program": "fish", "args": ["-l"]}` example loses integration under this rule, and the shell-integration note distinguishes user-written arguments from the ones detection supplies (Git Bash, WSL).
Part of #624; the native-input-mode half is separate.
Closes#624
tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.
The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.
The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.
`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.
Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.
Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>