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>
Every character came out as a different character, one for one, consistently
— it read as a broken locale or a mangled encoding, and it was neither.
Hack, the bundled default, has no CJK, so those cells are shaped through the
font-fallback chain. gpui's Windows backend then threw away the face
DirectWrite shaped the run with and looked a fresh one up by family, weight and
style. That round trip mapped DirectWrite's italic to oblique — the enum is
numbered OBLIQUE = 1, ITALIC = 2, and the mapping had them the other way around
— so an italic fallback face resolved to a request for an oblique one, and a
family with no oblique face (Maple Mono NF CN, first in our Windows chain) came
back as its upright face instead. The glyph indices were right; the outlines
they indexed belonged to a different face, at a fixed glyph-id skew.
Fixed upstream in our gpui fork by registering the face DirectWrite actually
chose rather than re-deriving one, which also closes a latent use-after-free in
the same cache: it keyed fonts by a raw pointer to a face nothing held a
reference to, so a released face could be aliased by any later allocation.
Bumps the fork pin; no tty7 code changes. Covered there by two tests in
`gpui_windows::direct_write` — one asserting a shaped run's glyphs round-trip
through the font id the run reports, one asserting every font-face cache key is
owned by the font it maps to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#214 added the switch itself; this is the wiring around it that a new
setting in this codebase is expected to carry.
- Index the row in `settings_search_entries`, which the settings search
box matches against. Without an entry, searching "dim", "fade" or
"unfocused" — the words someone actually looks for — finds nothing,
and the switch is only reachable by scrolling to it. Pinned in
`index_titles_match_rendered_row_labels` so the title cannot drift.
- Pin the default and the round trip, as every other `default_true`
flag here does (see `confirm_window_close_defaults_on_and_round_trips`):
a config written before the switch existed must still dim, and a
`false` must survive save/load or the effect comes back next launch.
- Hand the flag to `Pane::render` instead of reading the `Config` global
from inside it. `pane.rs` had no global state before, deliberately —
the leaf type is generic so the tree logic can be tested with plain
values. The caller already computes the split test the dimming was
gated on, so it can compute this too: one lookup per frame rather than
one per leaf, and the tree stays renderable without a Config global.
While there, `show_focus` is now named for what it does — nothing
drew a focus ring; it only ever gated the fade.
- Move the row below "Follow theme". That button clears the opacity and
blur overrides only, and a third row directly above it read as
something it would also reset.
- Changelog entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>