Five findings from review, all about where blocking work runs and what
a stale handle is still pointing at.
- `live_pane_count` ran a routed `List` — an SSH handshake, and on a WSL
route as far as installing the server — straight from the Stop/Delete
action handler. That is `guard_off_ui`'s debug abort in a dev build
and a frozen window in a release one. It is now split into a UI-thread
read and a background count, with the prompt raised through the window
handle afterwards.
- `teardown_workspace_forwards` blocked the UI thread on a daemon reply
that waits for the SSH server to acknowledge `cancel_tcpip_forward`.
On a machine that has gone unreachable — exactly when someone reaches
for Stop Workspace — it never came. Backgrounded, and `on_workspace`
now sets a read timeout so the thread is not parked forever either.
- The file tree's and editor's watch subscriptions had no record of
which host opened them. A reconnect inserts a fresh `RemoteHost` under
the same `HostId`, so `set_dirs` failed on a dead `ControlClient`,
was warned and dropped, and nothing opened a new one: after the first
reconnect the tree stopped seeing remote changes for the life of the
window, and the editor's external-change detection — what stops a save
clobbering someone else's edit — was silently off. Both now compare
the host by pointer and reopen when it differs.
- Closing a remote window that was empty *because its machine could not
be reached* deleted the workspace: its `RemoteRef`, cached layout and
geometry, while its panes were still running over there. Only a
machine that answered licenses dropping the entry.
- `HostOps` ran blocking calls on gpui's background executor, which on
Linux is a fixed pool with no blocking tier. Four stalled host calls
on a four-core client took every worker, including the one the
reconnect needed to clear the stall. They now run on their own elastic
pool.
Two scheduling bugs on the server side of a connection.
The pool asked `idle == 0` before spawning, but `idle` counts a worker
from before it parks until after it has re-acquired the lock on its way
out — so through the whole wake-up window a worker already handed a job
still looked free, and the second `notify_one` in that window went to a
thread that had left the wait set. A client pipelining k+1 frames onto k
parked workers left the last one queued behind a `git status`. The rule
is now "more queued than parked", which counts both sides of the window
and cancels it out.
`WatchOpen` started its forwarder before the reply carrying the watch id
was written, and both go through the same sink. A directory that changed
in that instant could push a batch the client dropped — it files the id
only once `call` returns — and since the tree relists only on a watch
event, that change stayed invisible. The forwarder is now parked and
started by `finish`, and dropped outright when no reply went out, so a
cancelled `WatchOpen` no longer leaves an OS watch behind either.
The takeover moves two things — the `WorkspaceStore`'s record and the
server's `AttachRegistry` handle — and each was internally locked, which
is not the same as the pair moving together. Two clients attaching one
workspace at the same instant could each win a different table, after
which the store named a session the registry had already evicted and no
`detach` could clear it: the workspace reported a takeover against a
client that had disconnected hours ago. Both moves now happen under one
handover lock, dropped before the displaced client is written to so a
peer that has stopped reading still cannot hold up the next attach.
`WorkspaceDelete` had the same split with no race needed at all: the
store dropped its attachment and the registry kept its handle, so the
next client to attach that id evicted a session nobody displaced — and,
that entry being dedicated, closed its whole link.
Two `tty7-server --stdio` sessions arriving while no daemon was up each
served in-process, each with its own store over the one file. `persist`
writes the whole document, so the second to save silently dropped the
first's changes, and their separate registries made takeover a no-op
between them. The probe path now starts the daemon and bridges to it —
the rule `bridge_panes` already follows one dialect over — and the store
re-reads when the file has moved underneath it, which covers the cases
where two writers are deliberate.
`MAX_RECORD_BYTES` and `MAX_WORKSPACES` did not bound their product:
seventeen maximal records put the array past `MAX_FRAME`, after which
every `WorkspaceList` was unencodable and every client showed an empty
list. The document is now bounded at the save, and only when growing, so
an over-large file can still be deleted back under the limit.
Six findings from review, all in the same seam — what happens when a
message cannot go on the wire, and who is allowed to conclude the link
is dead from that.
- `Sink`/`ControlClient::send` encode before writing, so 'this message
cannot be serialized' and 'this link failed' are distinguishable. Only
the second can have put bytes out, and only the second is grounds for
marking the connection dead — the client used to go `Reconnecting`
over an oversize `WriteFile` the server never saw.
- `Conn::finish` answers an unencodable reply with the error instead of
dropping it. Dropping left the client waiting out the request's whole
deadline for a reply that was never coming.
- `Search` drops the hits whose paths are not UTF-8 rather than letting
one Latin-1 filename make the whole reply unencodable. `SearchHit` is
the only `PathBuf` on this wire; serde refuses such a path outright.
- The control socket is bound under a tightened umask. `bind` creates
the node at `0777 & ~umask` and the `chmod` was a window — under
`umask 002` a group-connectable one, onto unauthenticated `ReadFile`.
- Binding no longer re-permissions a directory it did not create. With
`$TTY7_CONTROL_SOCK` or the hashed fallback the parent can be `/tmp`,
and 0700 there locks every other user out of it.
- A blob is filed under the `pending` lock, so a caller timing out in
the gap cannot leave a whole file's contents in the side table for the
life of the connection.
A Windows `SystemTime` is a FILETIME, whose tick is 100ns, so
`UNIX_EPOCH + Duration::new(_, 123_456_789)` came back as
`123_456_700` and the assertion failed on a rounding this conversion
never saw. Every nanosecond figure in the case is now a multiple of 100,
which still exercises the full nanos field.
`--stdio` is refused on Windows by design, and the control socket it
probes for is Unix-domain, so `stdio_conformance` and `workspace_store`
join `remote_router`/`routed_pane` in carrying a file-level `cfg(unix)`.
`cli.rs` keeps its argument-handling cases everywhere — `--version`,
`--help`, `agent-hook` and the usage error say nothing about transports
— and gates only the bridge and probe cases, which spawn a `--stdio`
child or stand up a listener.
Every test in `host::server`'s test module talks over a `UnixStream`
pair, so the module needs the same `unix` gate the `tty7-server`
integration tests already carry — without it the Windows leg of
`build & test` fails to compile `tty7-core`'s lib test with 19 errors.
The three pool tests are plain threads and channels, so they move to
their own module rather than being gated away from a platform they
work on.
Hook installation was written for one machine — this one — and a remote
workspace runs its agents on the far side of the connection, where none of
that is true: a different `$HOME`, a different filesystem separator, and a
`tty7` binary at a path this client published rather than the one it is
running from.
`HookTarget` is that machine, borrowed for the length of one background
task: `local` resolves our own home and binary, `remote` takes the home a
handshake reported and the `tty7-server-<version>` this client installed
there. Every path the installer builds now goes through it, via `Host::join`
rather than `PathBuf::join` — a Windows client installing onto a Linux box
was writing `/home/me\.claude`. The three things that are only true locally
(our own environment variables, atomic writes, running the `codex` CLI) are
gated on `is_local` instead of assumed. Settings grows a machine picker so
the page states which one it is acting on.
Also in this commit, three unrelated UI fixes:
- The settings sidebar's search placeholder sat 6px right of every nav
label under it — a `small` (14px) magnifier where the rows use 16, a 4px
gap where they use 8, and an `Input` that adds `input_px` (12px at the
default size) whether or not it draws a box. All three corrected, so the
placeholder starts on the rows' 32px text column.
- The "'X' is still running — reopen it from the workspace menu" toast is
gone, along with the `workspace_detach_hint_seen` flag that existed only
to show it once. Detaching a workspace is what ⌘W has always done here
and the Window menu already lists what came off screen; a one-time
lecture on top of that is noise. Old configs carrying the key still load
— `Config` doesn't deny unknown fields.
540255e dropped them to TILE_SIZE_SM to buy width back after the panel's
top row overflowed a 200px column, and used the size step to separate the
panel's own tabs from the window chrome sharing that row. Both arguments
hold. Neither survives what it looks like: a 24px tile with an 11px glyph
next to a 32px one with a 13px glyph doesn't read as a layer below, it
reads as shrunk — the panel's primary navigation drawn smaller than the
two buttons in the corner.
The width it was buying comes from MIN_WIDTH instead, which is where that
constraint honestly lives. Six 32px boxes, five 2px gaps and two
glyph-aligned insets need 214px, so the floor moves 200 → 216. The panel
was never usable at 200 anyway; what a tighter floor bought was an
overflowing row.
Reverts the tile size, the `rounded_md` that tracked it, and the two
insets that were switched to their `_sm` variants to match.
Folding three signed-off values into one `SELECTED` target is what made
the chrome shout. The old ladder had `hover` 0.09, `sidebar_sel` 0.12 and
`list_active` 0.17; the ratio rewrite kept only the loudest of them and
pointed every selected fill in the app at it. On the Light theme the
rail's selected row went from #E2E2E2 to #C0C0C0 — a silver slab at twice
the perceived step it had ever had — and it took the switch tracks, the
input grounds, the plain buttons' active state and every chrome tile's
*hover* (which paints the sidebar's selected grey by design) with it.
The two jobs were never the same:
- A resting selection — a rail row, a lit toggle, a switch track — sits
there all session next to unselected siblings. It stays quiet and leans
on the text channel, which is what `Surface` carries both for.
- A cursor — the one menu row under the pointer, the palette's keyboard
row — is transient and alone on its surface, and the eye is already
tracking it. It gets the loud rung.
So `SELECTED` drops to 1.30:1 and a new `CURSOR` takes 1.70:1, each
anchored to the Dracula value it was signed off at (`mix(bg, fg, 0.12)`
and `0.17` respectively). Dracula lands within 1.02:1 of both old greys,
and every other theme is pulled onto the same two perceived steps instead
of the same one. `PRESSED` follows `SELECTED` down to 1.55:1 — it is
"one step past selected", not a fixed distance from the surface.
Only `accent` (gpui-component's name for a menu row highlight) and
`list_active` move to the new rung; everything else was already asking
for a resting selection and now gets one.
The per-surface ladder test drops its floors to match and gains a
cursor-vs-selected assertion, so the two rungs can't quietly re-merge.
Opening, restoring, closing or listing a remote pane each ran a routed,
blocking connection straight from a gpui input callback, freezing the whole
window for the length of an SSH setup. Pane tree leaves become a PaneSlot that
can be Ready or Connecting, so the slot lands in the layout immediately and the
wait happens in it. connect_routed now carries the same debug UI-thread guard
the Host trait uses, so the class of bug fails loudly instead of shipping.
The workspace switcher is redrawn on one alignment axis with one icon size, a
guide rail tying a remote machine's rows to it, and the command palette's card
geometry. Launch restores only the workspace the user was last in.
The panel's title-bar-height top zone carried seven controls belonging to
three different layers: four panel tabs, the panel toggle, the "..." menu,
and the workspace chip. All seven at chrome scale, in a column the user can
drag down to MIN_WIDTH. The row wants 268px there — it overflows by 68px
before anything else goes wrong.
The layers are the real defect; the crowding is a symptom. Switching to
Files is "what does this panel show", the workspace chip is "which machine
am I on", and they sat side by side, same size, same weight.
Three changes, none of which touch the window layout — both columns stay
full-height and the corner controls stay where they are:
- The workspace chip moves to the head of the rail, as a full row with the
workspace's name rather than a monogram in a corner. The rail below it
enumerates that workspace's tabs, so this is where the name belongs; it
also stops a window-scoped control from competing for the panel's width.
Note it is *not* folded into the repo group headers under it — those are
repositories, and one workspace holds several.
- The panel's tab tiles drop to TILE_SIZE_SM. That constant exists for
tiles inside a panel, which is exactly what these are, and the one size
step separates them from the window chrome sharing their row without
spending a divider on it.
- On macOS panel_title draws nothing. The tile row above it already says
which tab you are on, and so does the content — a file tree is Files, a
diff is Changes. It cost a whole row: tiles, then a title, then a search
box, before one line of content. Changes' file count moves into that
tab's tooltip, where it stays readable without switching tabs. Outline's
count does not survive: it needs the active leaf, which needs a &Window
right_panel_tabs has no reason to take, and its list is one click away.
A tab passing `trailing` still gets the row; none currently do.
Off macOS nothing changes: the panel hangs below a spanning title bar
there, and panel_title is still that panel's tab switcher.
The top zone now needs ~164px against a 200px minimum.
resvg 0.47 landed in tty7 (#227) and the gpui fork (#237), but
gpui-component still declared its own resvg = 0.45.1, keeping a
legacy resvg/usvg/tiny-skia 0.45/0.11 stack in the tree. The fork now
pins 0.47 (l0ng-ai/gpui-component@2264ff99 — no source changes needed;
its only resvg user, the Windows native-menu rasterizer, uses APIs
unchanged across the bump), so this moves the pin and drops the last
duplicate: the lockfile now carries a single resvg/usvg/tiny-skia
stack at 0.47/0.12, and `cargo tree -i resvg@0.45.1` matches nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #227 bumped tty7's direct resvg to 0.47 while the gpui fork still
pinned 0.45, so the tree compiled two resvg/usvg/tiny-skia stacks. The
fork's tty7 branch now carries resvg 0.47 (l0ng-ai/zed@3aac3ef); move
the gpui pin there so gpui's SVG renderer and tty7's tray-icon
rasterizer share one 0.47 stack again.
gpui-component still declares its own resvg 0.45.1 (semver-incompatible
with 0.47), so one legacy 0.45 stack remains until that fork catches up
- noted in the manifest comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A remote workspace came back empty every time. The layout was thrown away
on both sides of design §10's storage split: `record_session` blanked a
remote entry's `session` before anything could push it, and
`claimable_session` blanked it again on the way out, so the record pulled
from the machine on connect was discarded the moment the window opened
from it. Nothing ever called `push_remote_layout` outside workspace
creation either, which left the machine's own `workspaces.json` holding a
record with no tabs in it.
The entry's `session` is now what it was always meant to be — this
client's cache of a record the machine owns — and the split is enforced
by reachability instead of by erasure:
- `claim` / `record` ask `machine_is_connected` first. A connected remote
window stores and reopens its layout like any other; an unreachable one
opens empty and, the half that matters, leaves the cached layout alone
so there is still something to rebuild from.
- `save_session` pushes to the machine that owns the record, so the
remote's copy tracks every structural change rather than only the
workspace's creation.
- `finish_attempt` hydrates a window that came up before its machine did.
Only an empty one: a window with tabs is one the user is working in.
The invariant the erasure was protecting still holds, and holds where it
belongs — every leaf routes through `pane_workspace_for`, so a remote
workspace's panes attach or spawn over there, and an unreachable machine
fails the spawn rather than falling back to a local shell.
Also stop matching a native-SSH leaf's `pane_id` against a remote
workspace's alive set. That pane lives in this client's daemon however
the window is bound, and pane ids are unique only within one daemon, so a
collision would have swapped the user's SSH tab for whatever the machine
happened to be running under the same number.
A watcher event dropped the cached listing for the directory it touched, so
on a remote host every row under that directory left the screen for a whole
round trip and came back — a file rewritten a few times a second made the
tree strobe. Mark the listing stale instead: it keeps painting while the
replacement flies, and is swapped out when that lands.
Also stop relisting a directory for its own watcher event. Whatever changed
inside it arrives as an event on that child, while the directory's own event
fires for everything inside it including the dot-files the row filter skips
on purpose — so relisting for it handed back exactly the round trip that
filter exists to save. A remote $HOME with an agent rewriting
`~/.claude.json` went from two relists a second to none.
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.
- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
static against musl and pushed onto the remote box. One dependency, on
purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
makes goes through the machine it belongs to. `LocalHost` answers on this
box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
of them run on the background executor with the result landed on the UI
thread; de-duplication, staleness and error reporting live here rather than
at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
workspace list → a window bound to one workspace on it. Workspace switcher
groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
cargo-zigbuild, a host-boundary grep, and version stamping factored out of
the nightly workflow. Both new jobs are non-required so branch protection
does not wedge open PRs.
Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
Three holes in the readline-parity work, found in review:
- The walk's reset lived only in `handle_editor_key`, but edits arrive by
other doors too — IME-committed text (all typing on macOS and Windows),
paste, a completion pick, cmd-Z. A repeat alt-. after any of those deleted
the recorded span even though it no longer held the walk's word. The walk
now stores the word itself and resumes only while the line still shows it
at `at` with the caret at its end; `commit_text` also clears the walk
outright, mirroring the key path's reset.
- A fresh alt-. over an active selection recorded `at` from the pre-insert
caret, but `insert_str` collapses the selection to its start first — so
the span pointed past the inserted word and the next press ate the wrong
text. `at` is now derived from where the caret actually landed.
- The unknown-Meta handoff built `ESC` + the key name, which gpui reports
unshifted — alt-shift-U shipped `ESC u` instead of `ESC U`. The shared
encoder now goes first (it knows the shifted character and the Kitty form
when `key_char` is present), with the hand-built fallback uppercasing
under Shift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows environment blocks are case-insensitive: portable-pty's
CommandBuilder keeps one slot per lowercased key, so a configured
`Term`/`ColorTerm` in `env` would land in the same slot as
`TERM`/`COLORTERM` and, coming later, replace it -- sidestepping the
rule that user env may rename the terminal but not contradict what the
pane's decoder implements. Filter capability keys with the platform's
own notion of "the same variable": case-insensitive on Windows, exact
elsewhere (where a differently-cased key is a genuinely distinct
variable and stays the user's to set). Pinned by a Windows-only test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependabot only rewrote Cargo.lock, but the manifest still required
resvg 0.45, so every `cargo build --locked` CI job failed with
"cannot update the lock file". Bump the requirement to 0.47 and update
the pin-rationale comment: gpui/gpui-component still carry resvg
0.45.x, so a second resvg/usvg/tiny-skia stack now compiles until the
fork catches up (no type conflicts; only image::RgbaImage crosses the
tty7/gpui boundary).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependabot bumped sha2 to 0.11.0 in Cargo.lock but left the manifest
requiring 0.10, so --locked builds failed on every platform. tty7's
only sha2 usage (Sha512::digest in core::keychain) is unchanged in 0.11.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
native_cell_residue was inserted between seg_clip_width's doc block and
the function itself, so rustdoc attached the clip-width prose to the
residue helper and left seg_clip_width undocumented. Move the helper
(with its own doc) above the block instead. No code change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The render call site still said "show focus rings only when split" -- the
same misdescription the parameter rename in this PR removes: nothing ever
drew a ring, the flag only gated the fade, and the fade condition is now
spelled out two lines below.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two follow-ups to #229.
Underlines ride on the `TextRun` that `paint_glyphs` builds, and the Solo
arm returned early for every natively-drawn cell — so an `ESC[4m` span or a
hovered URL showed a one-column hole wherever it crossed a box-drawing
character. The mechanism predates #229 (the Powerline branch has always had
it), but #229 widened it from a dozen private-use separators to all 256
characters of U+2500–U+259F. Such a cell now shapes a space in its own
style instead of returning, so gpui draws the line from the same
`UnderlineStyle` — curly and double included — that every other cell uses.
Stroke weight varied between cells at fractional device scale. `rectb`
snaps a rect's two edges independently, which is what makes neighbouring
cells tile, but two edges `w` apart land `w × scale` device pixels apart:
when that is not a whole number the two roundings straddle it, so a
1-logical-pixel rule came out 1 device pixel wide in one column and 2 in
the next. At Windows' default 125%/150% scaling that alternated thin/thick
across every column of a TUI table, and down every row for horizontal
rules. Integer scales are blind to it by construction, which is why 1x and
2x looked right.
`light_thickness` now quantises to whole device pixels, and `vstroke` /
`hstroke` lay that width off from the snapped near edge rather than
inferring it from a second snap — float ties at `.5` made the quantisation
alone insufficient. Stroke ends still snap, so #229's tiling guarantee is
untouched. Block elements stay on `rectb`: they are area fills, not
strokes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>