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.
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`.
Tab at a path position in a native-SSH pane had nothing to offer: path
candidates come off the local filesystem, and a remote pane deliberately
passes no local cwd, so `complete` returned `None`. A no-match hands the
line to the shell (#136), which costs the user the inline editor until
the next prompt — so in practice every path Tab in an SSH session dropped
them back to the raw shell for the rest of that line.
Ask the far side instead. The pane already holds an authenticated russh
connection, and `SftpList` — the same request the SFTP panel browses with
— lists a remote directory over it. No shell involvement: nothing is
echoed into the scrollback, no prompt hooks need suppressing, and there
is no background process to cancel when the user submits.
The listing is a network round-trip, so it can't answer the keystroke
synchronously. Results land on the main thread and only then behave as a
local Tab would; a line that moved on in the meantime drops the answer,
and an empty one falls back to the shell handoff, so a directory we
cannot read is no worse off than before.
completion.rs stays pure: it splits the word into a request and turns the
answer into candidates, both unit-tested. Candidate rules mirror the
local path engine exactly — hidden entries need a dot prefix, `cd` takes
directories only, symlinks follow to their target's kind, same ordering
and cap. Positions a listing cannot help (command position, `~` without
the remote's $HOME, no absolute remote cwd) decline and let the shell
have the Tab.
Only native-SSH panes qualify: a foreground `ssh` typed into a local
shell and a WSL pane have no tty7-owned connection to ask.
The OSC sniffer folded a whole chunk's 133 marks into a single shell
state and `apply_signals` sent one `Prompt` frame for it. That loses
information the client depends on: it counts `at_prompt` false -> true
edges as prompt *cycles*, which is how it tells a fresh prompt from a
same-prompt redraw (a prompt framework re-emitting the PS1-embedded
`133;B` on reset-prompt bumps the report seq but must not count).
A chunk carrying a whole command cycle (`C` ... `D`) therefore reported
only the final at-prompt state, and the edge vanished. Over SSH that is
routine rather than exotic: a fast command's `C`, its output and its `D`
leave the remote together and arrive in one read.
Report one state per crossing instead. Marks on the same side of the
boundary still fold into their latest state, so the ordinary `D`/`A`/`B`
prompt draw costs exactly one frame as before.
Every config-dir file is read by a loader that treats any parse error as
"there is no file" and falls back to defaults. serde_json rejects the
U+FEFF a BOM puts before the opening brace, so a BOM never surfaced as a
broken config — it surfaced as an absent one, and the app booted on
defaults with nothing to explain it.
Windows makes that easy to hit by accident: PowerShell's `>`, `Out-File`
and `Set-Content -Encoding utf8` all write a BOM, so editing config.json
from a shell was enough to lose every setting.
Strip a leading BOM in the three loaders whose files people hand-edit:
config.json, session.json (which dropped every workspace the same way),
and themes/*.yaml. read_to_string decodes the marker to one U+FEFF char,
so this strips the char, not the three raw bytes — and only the first
one, since a second is content the parser should still reject.
window.json and update.json are left alone: they are machine-written
state a relaunch rebuilds, never hand-edited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
The combining-mark cases carry exactly one mark — `e` + U+0301, and
❤ + U+FE0F. Thai routinely stacks two on one consonant: `ที่` is U+0E17
with an above-base vowel and a tone mark both sitting on it, and
Devanagari, Arabic and Hebrew stack the same way.
Nothing pinned that. Truncating the mark iterator to its first element
leaves all thirteen `segment_row` tests green — the cluster still forms,
still spans one column, and still leaves its neighbour intact. Only the
trailing codepoint goes missing, which in Thai is the one carrying the
tone: ไม่ (not) and ไม้ (wood) both collapse to ไม.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Cell::zerowidth` answers out of alacritty's one lazily boxed `extra`,
which also holds SGR 58 and OSC 8 — so a cell carrying nothing but an
underline color or a hyperlink reports `Some(&[])`, not `None`.
`snapshot_cell` mapped that straight into `RenderCell::marks`, and an
empty list is still `Some`: every SGR-58 or OSC-8 cell then failed
`is_blank`, broke the run it sat in, and took a `RowSeg::Cluster` of its
own — one `shape_line` per character for a whole `ls --hyperlink`
listing, with the powerline fast path skipped on the way past.
Filter the empties at the source, where the `Option` is built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
`Config::load` turns any parse error into *defaults*, so a config that
fails to deserialize doesn't fall back field-by-field — it comes back with
`confirm_window_close: true` and nothing said. The struct has no
`deny_unknown_fields` today; this pins that, since the opt-out is exactly
the setting whose silent reversal nobody would notice.
Also index the Windows/Linux spelling of the chord: the prompt is reached
by Ctrl-W off macOS, and search only knew "cmd-w".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
`/proc/<pid>/cwd` still resolves after the directory is unlinked, and the
kernel spells that `<path> (deleted)` — a label, not a path. That was
harmless while the reading only backfilled `List` for a pane with no cwd
at all; now that the poll broadcasts it, one `rm -rf` from another pane
(or a `git clean`) leaves every new tab, split, git probe and path
completion pointed at a directory that cannot be opened, and the pane's
last good directory has already been overwritten.
Treat an unstat-able reading as no reading, so the foreground group's
answer is discarded and the shell pid gets its turn — the same shape the
rest of the chain already uses for a pid it cannot inspect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
`probed_cwd_keeps_the_shells_spelling_for_a_symlinked_path` built its
fixture with `std::os::windows::fs::symlink_dir`, which needs
`SeCreateSymbolicLinkPrivilege` — a privilege an unelevated shell without
Developer Mode does not hold. The test therefore failed on any normal
Windows checkout with `ERROR_PRIVILEGE_NOT_HELD` (os error 1314) before
reaching a single assertion, observed here on Windows 11.
Nothing is lost by making it `cfg(unix)`: `foreground_cwd` answers `None`
off macOS/Linux, so no Windows pane ever reaches that comparison. What
every platform *does* run — a reading identical to the one already
reported, resolved with no filesystem access at all — gets its own test
instead, because a re-send there would push a `Cwd` frame and the git
probe behind it twice a second for the life of an idle pane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
An emoji written as base + U+FE0F rendered wrong twice over. Both halves
came from the variation selector arriving as a zero-width combining mark
after the column budget was already spent.
Width, in the `alacritty_terminal` pin (bumped to the fork's b79e704):
`input` reserves columns one `char` at a time, so a base whose East
Asian Width is Neutral -- U+2764 in `❤️`, U+1F5C2 in `🗂️`, U+26A0 in
`⚠️` -- kept the single column it was given, its glyph bled over the
next cell, and every column after it on the line shifted left by one.
The fork re-scores the sequence with `UnicodeWidthStr`, which is where
UTS #51's width-2 rule lives, and widens the cell to match.
Presentation, here: `snapshot_cell` copied only `cell.c` into
`RenderCell`, so `cell.zerowidth()` was dropped before the shaper ever
saw it. `❤` and `❤\u{FE0F}` reached gpui as the same string and picked
the same text-presentation face -- a black heart where every other
terminal shows a red one. `RenderCell` now carries the marks and a new
`RowSeg::Cluster` shapes them with their base. That restores every
combining mark, not just the selectors: `e` + U+0301 was being dropped
the same way.
A marked cell never joins a batched run. Marks add characters without
adding columns, which is exactly the correspondence `force_width` uses
to pin one glyph per column in a `Run` or `Wide` segment.
Fixes#203.
A pane learned its working directory from OSC 7 alone, which only shells
tty7 manages to inject its integration into ever emit. A shell that execs
into another one from its rc file (`exec fish` at the end of .zshrc), a
nested shell started by hand, or any shell with no integration at all
emits none — and since a pane's cwd is seeded with its spawn directory,
such a pane does not report *no* directory, it reports a permanently
stale one. New tabs and splits, the git probe and path completion all
follow it to the wrong place, with nothing on screen to say why.
Read the cwd from the process table too, on the same half-second
foreground poll that already detects SSH sessions and coding agents, and
reconcile it with what the pane reports:
- A remote pane is left alone. The local process table can only see the
ssh client's own directory, which is the confusion apply_remote_context
clears the cwd to avoid in the first place.
- No reading is "nothing to read", never "no cwd", so it cannot clear one.
- When both name the same directory the shell's spelling wins. $PWD keeps
the symlinked route the user walked in through, and that is the path a
new tab should open in; only a genuine disagreement moves the pane.
The three platform cwd readers move from DaemonPane methods to free
functions so the reader thread can call them; the method stays as a thin
delegate for List.
Fixes#187
Closing the last window pops "Close Window?" every time. The prompt was
only ever teaching, not protection — Cmd-Q, the tray's Quit and the
palette's Quit all leave without asking, and nothing is lost either way
since the panes keep running in the daemon. Once the user knows that,
being asked on every quit is friction.
Adds `confirm_window_close` (default true, so nothing changes for
existing configs) and a Window & Tabs toggle. Off makes the last window
close like any other: detach the workspace, quit.
`segmented` paints its own opaque track — deliberately, since every rung
above it was derived against that ground. But it hardcoded the window
surface, and one of its sixteen call sites is the managed-forward form in
the right panel, which is a sunk `sidebar` rail: the track landed as a
faintly darker box cut out of the column around it, and its hover /
selected / label rungs were measured against a ground that was not there.
Split off `segmented_on`, which takes the `Surface`; `segmented` keeps the
window default the settings sheet wants.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
The new hover-invalidation test read 24 rows into the pane because the
harness names that size, but the test window lays itself out first and
`set_grid_size` had already moved the terminal to the measured geometry
— so the "same size changes nothing" call was a size *change*, and it
cleared the cell the assert expected. Set the starting geometry from the
test instead of inheriting whatever the window measured. Unix-only
module, so this only shows up in CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
Two gaps in the interaction-state change.
`Theme::input` reaches the *outline* button path, which reads the field
live, but not the plain one: gpui-component derives `tokens.button_hover`
and `tokens.button_active` from `input` once, inside the `apply_config`
that `Theme::change` runs — from the stock `#2f2f2f`, before any of
`apply_theme`'s overrides exist. A snapshot never sees the fix, so a plain
`Button` still hovered and pressed in that grey and `Button::selected`
(the terminal search bar's `Aa` / `.*` toggles, the last two left in the
app) filled from `tokens.secondary_active` the same way — ~1.03:1 on
Dracula, i.e. issue #197 again, one snapshot removed from the field that
fixed it. Point those state rungs at the window ladder. `tokens.button`,
the resting fill, is deliberately left alone.
`legible_ink` picked its extreme with `is_dark`, whose 0.5 luminance
threshold is the wrong question: on a midtone ground (luminance
0.18…0.5) it says "dark" while black outreaches white, so a status seed
was driven toward white and clamped there *below* the floor with its hue
gone — precisely when a user most needs to tell an error from a warning.
Take the reachable extreme instead, as `legible_foreground` already does.
Every built-in sits far enough from the midpoint that this picks what
`is_dark` did, so the derived palettes are unchanged; only an imported
scheme could reach the divergent range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
Two gaps left by the hover fix, both the same crash class.
`grid_smart_range` indexes `grid[click.line]` the moment it starts —
`hyperlink_run`, then `logical_line_at` — with the row a double-click
carried in from the frame that dispatched it. That row can outlive its
grid the same way a hovered cell can: a split, a window drag, or the
reader thread applying the daemon's replayed attach geometry between
paints. `Grid`'s `Index<Line>` only `debug_assert`s the bound, so a
release build walks off the storage, and the click arrives in a gpui
`extern "C"` callback where the panic aborts instead of unwinding.
Guard the row up front, exactly as `TerminalView::grid_line` does.
`set_grid_size` forgot the hovered *cell* but kept the link it had
resolved. That link is stored in grid coordinates, so after the reflow
it underlines whatever text now sits at those coordinates and holds the
pointing-hand cursor over it until the pointer moves again — clamping's
wrong-but-silent hover, one step removed. Drop it with the cell.
Both halves get a regression test; the smart-select one panics on the
storage `debug_assert` without the guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
`title_bar_drag` reached for `window.titlebar_double_click()`, which gpui
only implements on macOS — the platform trait method is an empty default
everywhere else. On Windows that costs nothing (the row is a drag area,
so it is HTCAPTION and the OS has already toggled the window before the
app sees anything), but on Linux the row swallowed the double-click and
nothing happened, on all three rows the helper drives: the rail's top
zone and the code and diff overlays' headers.
`zoom_window()` is the maximise toggle there, and what gpui-component's
own `TitleBar` calls on Linux for exactly this reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
Selected options were indistinguishable from their neighbours on every
bundled theme, worst on Dracula where a segmented control's active
segment measured 1.03:1 against its siblings (issue #197). The cause was
not Dracula: the app had no state model. Interaction state was a set of
fixed `mix(bg, fg, t)` ratios scattered across `neutrals()` and
`.opacity()` multipliers at call sites, and the fields nobody had
noticed — `input`, `ring`, `switch`, `switch_thumb`, `danger`,
`warning`, `success`, `link` — silently kept gpui-component's stock
values, greys and Tailwind hues with no relationship to the active
theme.
Introduce the state model the app was missing.
`Surface` is the interaction-state ladder for one painting surface, and
every rung is derived to hit a *contrast ratio* against that surface
rather than a fixed blend ratio. A fixed blend makes the perceived step
depend on the seed: selected-vs-resting ranged from 1.20:1 (Catppuccin
Latte) to 1.47:1 (Dracula). Every theme now lands on 1.70-1.72:1. The
target is 1.70 because that is where the already-signed-off Dracula
highlight sits, so the change is a no-op on the theme the look was tuned
against and a lift for the rest.
Ladders are per surface. Menu rows paint on `popover`, not the window
background, which is why the context-menu highlight measured 1.20:1
against the panel it actually sat on while nominally being the fill that
reads fine on the terminal ground.
A fill alone never communicates selection, so `Surface` carries the
label colours too: take a surface, take both channels. The app had
already learned this three times locally — the tab chip, the chrome
tiles and the sidebar each grew a hand-written fill-plus-text pair —
while every site that had not been hand-fixed shipped a fill and nothing
else. `ink_on` keeps a selected label readable on its own fill; without
it, raising a fill toward the foreground drags the label under the
resting ones around it (4.14:1 on Latte).
`Semantics` derives danger/warning/success/info/link from each theme's
own ANSI-16 instead of stock Tailwind, so a danger marker and an error
line of shell output are finally the same red. Conditioning drives a
seed toward black or white rather than toward the foreground: routing
through Rosé Pine Dawn's purple-grey foreground collapsed its red and
yellow into two indistinguishable mauves.
Switch toggles read inverted on every dark theme because the knob fell
back through two unset fields to a stock near-black. The knob now takes
the light end of the theme's axis, the unchecked track comes off the
ladder, and the checked track takes the accent — hue is the only axis
left once the knob has claimed lightness, which is why every system
switch is coloured.
`segmented` is hand-rolled. The stock `ButtonGroup::outline()` path gave
the selected segment the same border and the same label colour as its
siblings, leaving one fill as the entire signal, and exposes no way to
vary weight.
Nine tests pin the properties rather than the current taste, so retuning
the four contrast constants does not force a test edit but abandoning
the model does.
`panic!` records the location with the platform's own separator, so the
assertion on `src/core/crash.rs` failed the Windows job while the hook
itself worked fine there.
Hovering a pane records the cell under the pointer so ⌘-hover can
underline links. Nothing invalidated that cell when the grid shrank
under it — a vertical split, un-maximizing a pane, a smaller window —
so the remembered row could name a line the grid no longer has.
The next modifier press then walked into it: ModifiersChanged →
set_link_modifier → every pane in every tab → link_span_at, which
indexed the grid with the stale row. Grid's Index<Line> only
debug_asserts the bound, so a release build underflows the storage
index and panics on the slice check. That panic happens inside gpui's
extern "C" input callback, where it cannot unwind — the process
aborts, and the OS crash report keeps the abort rather than the panic.
Validate the row against the grid before indexing (one choke point,
shared by hover and click), and drop the remembered cell on resize.
Two more ways the same class of crash reached users:
- `new_terminal` unwrapped a failed daemon spawn with `expect`, so a
daemon that died took the app down with the next ⌘D/⌘T instead of
reporting the failure. It returns a Result now; split, new tab,
worktree tab and reopen-closed-tab surface a notification, and
session restore drops the leaves it can't rebuild.
- A spawn that fails because nothing is listening on the socket now
starts a daemon and retries once, the way a disconnect mid-Spawn
already did. `connect()` had to stop interpolating the io::Error
into a string for that classification to see it.
And so the next one is diagnosable at all: a panic hook writes the
message, location and backtrace to `crash.log` in the config dir. The
GUI has no logger and no stderr when launched from Finder, and the
crash report loses the message to the abort.
Fixes#201
macOS fills the window's leading corner with the traffic lights and
`TITLE_BAR_LEAD` reserves them 80px. Everywhere else that corner held
nothing: the caption row's only contents are the rail's "+" and collapse
at the rail's right edge and the corner chrome at the window's, so the
left third of the row read as unfinished rather than restrained — while
Windows treats the top-left as the app's identity slot.
Three parts, all of them about that row:
- `window_mark()` draws the "duo" mark (the app icon's own art) at the
head of the rail on `CONTENT_INSET`, the line the search box and every
row label below it start on, and follows the rail's controls into the
title strip when the sidebar collapses. It is drawn, never clicked: no
hover capsule, and deliberately no `occlude()`, so the drag region
underneath still takes the press and the strip stays grabbable.
- The rail's stand-in row now reserves the same hairline the real
`TitleBar` draws inside its own height. Without it the bar centred
content on 19.5 and the rail on 20, and the mark hopped half a pixel
as collapsing the rail handed it from one to the other.
- With the detail panel open off macOS the bar is hoisted above
`[terminal | panel]` so the window controls can reach the corner, which
left the code and diff overlays — anchored to the terminal column —
starting 40px down, with headers drawn to *be* the title bar landing a
row low. They now hang on the row that owns the bar, inset by the
panel's width. Covering the caption row that way needs the headers to
carry its gestures, which neither ever did with the panel open or
closed: `title_bar_drag()` gives both (and the rail's row, which grew
the same wiring by hand) drag-to-move and double-click-to-zoom, and
their controls are `occlude()`d so HTCAPTION stops eating the clicks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
The default `font_fallbacks` list was macOS-only -- Menlo, Hasklug Nerd
Font Mono, Maple Mono NF CN, Apple Color Emoji. Fallbacks resolve by
family name against installed fonts, so off macOS the whole chain matched
nothing and every glyph the primary lacked was left to the platform's own
cascade. Bundled Hack maps 1548 codepoints and zero ideographs, so on
Windows that was every Chinese character in every pane, and every emoji.
The fall-through is not only a matter of which face you get. `element.rs`
pins each wide cell to `2 x cell_width`, and Hack advances 0.60205em, so
a two-column slot is 1.2041em -- while every stock CJK face (Microsoft
YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. `force_width`
left-aligns, so the ideograph hugs the left of its slot and the remaining
0.2em shows up as a gap on the right of every character. Measured on
Windows at font_size 15: left bearing 1.49px, right bearing 4.90px.
Branch the defaults per platform, keeping Maple Mono NF CN first
everywhere -- 0.6em Latin, 1.2em CJK, the one exact two-cell fit against
Hack (bearings 3.06px / 3.62px, ink centered). It stays referenced by
name only, never bundled, at ~20MB per weight.
Changing `Config::default` alone would reach nobody who already has a
`config.json`, which is every existing user. So `fallback_chain` appends
the platform's stock faces the same way it already pins Hack: a fallback
is consulted only after everything ahead of it has missed, so appending
can never displace a face the user chose, and the file is never rewritten.
Verified by driving two builds against one config naming only absent
macOS faces: before, the CJK line differed from an explicit Microsoft
YaHei chain by 3571 pixels (the cascade picked something else); after, it
is pixel-identical.
The detail panel (Info / Outline / Changes / Files), the remote SFTP
listing and the tab rail all scrolled with gpui's `overflow_y_scroll()`,
which paints nothing: a deep file tree or a long tab list gave no hint
that there was more content, or where in it you were (issue #185).
Hang gpui-component's `Scrollbar` over each of those containers through
one shared helper (`ui::scrollbar`). The handle stays app-owned so
`scroll_to_item` keeps working — that rules out gpui-component's
`overflow_y_scrollbar()`, which mints its own handle internally.
Theme the bar from the active theme's own background→foreground mix
ladder rather than the stock fixed greys, and keep the track transparent
so the thumb floats instead of laying a slab down the panel edge.
Show mode follows the platform: auto-hide on macOS (and anywhere else the
OS uses overlay scrollbars), permanently visible on Windows and Linux.
gpui-component's own default picks `Hover` there, which only reveals the
bar within 16px of the edge — that is the "no scrollbar at all" the issue
reports from Windows.