The palette is walked with the arrows and run with Enter; Tab has no job
there. Root's focus walker had one for it anyway, and sent it out of the
modal onto whichever chrome tile sat behind the scrim — the New Tab plus,
the panel toggle — which lit up with a focus ring the palette had no way to
take back.
Tab and Shift-Tab bind to `NoAction` in the palette's own context, which is
deeper than Root's and stops the search before the walker is reached.
The panel's footer says Tab is the way across once a query is in the box,
and it never was. gpui-component's Root binds `tab` to its focus walker,
gpui dispatches bindings *before* key listeners, so `on_switcher_key` never
saw the key: Tab put a blue focus ring on the ellipsis button in the
panel's own header and left the columns where they were.
Tab and Shift-Tab now bind to the panel's own key context, which sits
deeper in the dispatch path than Root's — the same shape the terminal
already uses to keep Tab for the shell.
Crossing over only helps if you can see where the cursor went, and you
could not: both columns keep their rows inside one child element, and
`ScrollHandle::scroll_to_item` indexes a scroll's *direct* children, so it
could only ever find item 0. Walking a long tab list quietly left the
selection below the fold. The selected row now wears a `ScrollAnchor`, and
stepping the cursor scrolls to it — in either column.
The tab sidebar, the file tree, the right panel and the SFTP browser all
run their scroll area through `with_vertical_scrollbar`. The settings
pages — the longest scrolling surfaces tty7 has, several viewports of them
on Appearance and Terminal — had no bar at all: no thumb, no sense of how
far down the page you were or how much was left. The SSH host list, the SSH
form and the theme picker's list were the same.
All four now carry the shared bar. It follows the OS "show scroll bars"
preference like every other one, so nothing appears for anyone who asked
for scrollbars to stay hidden.
The scroll areas move inside a wrapper that holds the constraints they used
to hold themselves, and two of those do not survive the move. `min_w_0` on
a column child means the *cross* axis, and carrying it inside let the SSH
form's label column shrink to nothing while the toggles stayed put; it
belongs on the wrapper, which is still the row item it always was. And the
wrapper has to take its width explicitly rather than by stretching, or the
`w_full` inside has no width to be a percentage of — which is how the
settings reading column lost its 640px cap on the Chinese page, exactly the
way 00ad4eb first found it.
`chrome_tile_variant_for` passed `sidebar_accent` to both `hover` and
`active`, and `sidebar_accent` *is* the sidebar surface's selected step. So
a hovered tile wore the exact fill of a selected one: with the right panel
open, pointing at Changes made it and the current Files tab read as two
current tabs, told apart only by a shade of glyph.
The palette already derives the step below it — 1.18 from the surface where
selected is 1.30, with a test holding them apart — and hover now takes it.
A selected button never renders the hover style, so the selected tiles are
untouched; every other tile simply stops overstating a pointer.
Hovering the tiles either side of it names them — Switch Workspace ⇧⌘O,
More, Hide Sidebar — and the plus between them said nothing, in all three
places it appears: the sidebar head, the top strip, and the collapsed rail.
It now names itself and its chord, from the one helper all three share.
`switcher_hint` was that helper for exactly one tooltip; it becomes
`chord_hint(what, action)` so the second caller does not copy it.
Typing in the settings search dims the page and lights up the rows that
match, and the nav badges count them per section. But nothing moved the
page. Search "cursor" and Appearance says (2) while the screen shows a
greyed-out Theme section: both matches live under Cursor, most of a page
below the fold, with no sign they are down there. On Terminal the single
match sat right on the bottom edge.
The content pane now carries a ScrollHandle, and the first matching row on
the page claims a ScrollAnchor on it. Whenever the query changes, or a
section is opened with a query already live, the page scrolls that first
match into view — one row per page, so the view never travels past matches
above it. Clearing the query leaves the page where the reader left it.
The host rows carry a liveness dot in front of the title; Defaults, which
has no session to be live, carried nothing and skipped the space too. Its
title and subtitle therefore began 14px to the left of every host title
under them — the one row at the top of the list, hanging off the column the
rest of the list holds.
The gutter is now unconditional and simply stays empty on the rows that
have no dot to put in it. The group headers were 2px off the same column
for the same kind of reason: 6 + 10 + 4 instead of 8 + 6 + 8. Their inset
becomes the 8px the rows already hover with, which lands them on it.
`cargo fmt --check` is the first job CI runs, and main passes it. Three
files on this branch no longer did: a long method chain in the settings
shell picker, the card-width clamp the switcher gained when it learned to
fit its window, and a Chinese plural that shrank below the line limit when
its backticks came off. None of it changes behaviour — it is what rustfmt
would have written, restored so a merge does not fail on formatting.
Right-clicking a pane offers "Split Right ⌘D", "Close Pane / Tab ⌘W" and
six more with their shortcuts printed, because those items are built with
`menu(label, action)` and PopupMenu looks the binding up from the action.
Right-clicking the tab that owns the pane offered the same commands as bare
labels — every item there is built from a click handler instead, since it
has to act on that tab rather than the active one, and a handler carries no
action to look up.
The items keep their handlers and now carry the matching action purely so
the chord can be found: `confirm` prefers the handler whenever both are
set, so nothing about what a click does changes. Split Right, Split Down
and Close Tab pick up the chords they have always had; the rest are ready
for the day someone binds them.
The home page is what a window shows with no tabs open, and it listed
Split Right and Split Down among the things you can do from there. Both
ask the active tab for a pane to split and return without a word when
there is none, so the two chords the screen taught did nothing on the one
screen that taught them. Reopen Closed Tab had the same shape whenever the
closed stack was empty.
The splits leave the list, and Reopen Closed Tab appears only while there
is something to reopen. A fresh window now offers four rows, and every one
of them works.
Nine strings wrapped a command in backticks — `tty7`, `git diff`, `git
status`, `{alias}` — and nothing in this app renders Markdown, so the
marks landed on screen as literal characters. The Settings label read
"Install the `tty7` command on PATH", and its description wrapped mid-token
and left a lone backtick opening a line.
Everywhere else the same copy writes known_hosts, ~/.ssh/config and
"git diff →" bare, so the backticks go and the minority follows the
majority. The one that quotes a name — the missing ~/.ssh/config alias —
takes the quotes the rest of the app already uses for a name: "..." in
English, “...” in Chinese.
An icon-only xsmall button is a 20x20 box, under the 24x24 desktop floor
in WCAG 2.5.8 that hit_target exists to hold. The glyph is unchanged; only
the box it sits in grows.
The card is fixed-size on purpose — one that resized under the pointer
while the tab column changed length would be unusable — but 840x420 was
fixed in the absolute sense too, so on a small window it hung off both
edges with its search box reading "orkspaces, tabs and machines". It now
takes what the window has when that is less than what it wants, and is
untouched above that.
Nothing stopped tty7 being dragged down to a couple of hundred pixels,
and below roughly 700 the chrome stops being chrome: the sidebar sits at
its 180px floor with barely forty columns beside it, and the overlays the
app drops on top have nowhere to land. 720x520 is the smallest window it
still holds its shape in.
The control never shrinks and the label column must keep min_w_0 or long
descriptions stop wrapping, so on a narrow pane the label was squeezed to
nothing: at 600px the Terminal page rendered "Program" as a vertical
column of single letters, and the SSH form did the same below about
1000px, which 4275bb1 left open as the real remaining bug.
flex_wrap was the wrong answer — it let the label column size to its own
description, which then ran out past the row on every wide page. Measure
instead: render_settings knows the viewport and which page it is drawing,
so it can say how much width a row will actually get, and the row lays
itself out side by side or stacked from that. Nothing moves above the
breakpoint.
The Preset and Prefix rows on the Keybindings page are hand-rolled rather
than built from settings_row, and they were missing the one thing that
makes every other row survive a narrow window: a label column allowed to
shrink. At 560px the tmux description ran off the right edge and took the
Default/tmux control with it. Same gap_8 as the shared helper, so nothing
moves on a wide window.
Four Chinese descriptions already used “ ”, but the nineteen strings that
quote a name — a file about to be deleted, a machine about to be
restarted — used ASCII " ", which in a run of Chinese text reads as two
stray marks. Japanese quotes with 「」 throughout; Chinese now does the
same with “ ”, and drops the space that a full-width quote does not need.
It was the only English row label in a Chinese Scrolling section, sitting
between 滚动速度 and 平滑滚动 with its own description already in Chinese
— Japanese had translated it, Chinese had not. zh keeps English for the
tokens you type (shell, PATH, SSH); a row label is not one of those.
On the Chinese and Japanese pages the theme card ran the full width of
the window while every other row, and the dividers between them, stopped
at 640 — and its "Change theme" chevron, which the card pushes to its
right edge, ended up stranded in the middle. The column carries
`w_full` under `max_w(640)`, and with nothing definite to resolve the
percentage against it falls back to what the content measures, where the
card's own row wins over the cap. Give the padding box a width.
Escape closed the whole Settings page even with the theme picker open, so
a panel opened a moment earlier went with it and you had to walk back to
Appearance to try again. Peel one layer at a time: the picker first, then
the page.
The panel leads with a Search themes box and opened with the caret still
back on the page, so the first thing typed at a panel whose whole job is
picking one of nine themes went nowhere. Closing hands the caret back to
the settings search rather than leaving it on a box that is no longer
drawn.
A tab title that is a path is elided to its last three segments, capped
at forty characters, and then truncated again by the chip — so a tab
could read "…/src/daemon/install" with no way to find out which checkout
that was. Name it on hover, in both the strip and the sidebar, and only
when something was actually dropped so tabs that already show their whole
name stay quiet under the pointer.
button_foreground and secondary_foreground both resolve from
gpui-component's stock foreground, and apply_theme never overrode them —
so a button's label and the detail panel's section headings were the only
text in the window not written in the theme's own colour. On Rose Pine
Dawn that is near-black next to the preset's #575279 everywhere else.
A chip cannot shrink below 100px and the row simply clips, so past six or
seven tabs the ones at the end were not drawn — including, right after
⌘T, the tab that had just been opened and made active. The strip now
draws the run of chips that holds the active one, sliding by as little as
it takes.
The New Tab button was a second casualty, and only when the detail panel
was open: on macOS the panel draws its own title-bar chrome, so the strip
stops at the panel's edge, but it was still sized to the whole viewport
and reserved room for chrome it no longer holds. Measured at 1000px with
the panel open: four chips and a + where there were six chips and no way
to open a seventh.
drag_border was never set, so the sidebar and panel splitters, the pane
divider and the drop target for a dragged-in file all lit up in
gpui-component's stock blue — the same blue under all nine presets.
Measured on Rose Pine Dawn: the handle went from #656361 to #907aa9, the
preset's own accent, which is already what the focus ring and an on
switch use.
confirm_answers settled this for every native alert, but the four sheets
tty7 renders in-window kept the mirrored order: the SSH password, key
passphrase and keyboard-interactive sheets, the two host-key sheets and
the worktree sheet all led with the action, left-aligned, while the SFTP
rename and the port-forward form beside them already right-aligned with
Cancel first. On the changed-host-key sheet that also moves Abort — which
was already the emphasized answer — to where the eye lands last.
kind_color() reaches for theme.green, .cyan, .blue, .yellow and .magenta,
and apply_theme never set any of them — so every dark preset shared one
stock ramp and every light preset another, and the line you were typing
kept a palette the output right above it had already left behind. Point
them at the preset's ANSI ramp, cleared for legibility through the same
floor the semantic inks already use.
apply_theme set tokens.popover_foreground but never the field beside it,
and that field is the one gpui-component reads for tooltips, dropdown
menus and date pickers — they all kept the stock near-white and came out
brighter than the window they float over. It also inverted the terminal's
own menus: the completion and Ctrl+R rows painted resting text with it
and the selected row with the preset's foreground, so the selected row
was the dimmest line in the list. Wire the field up, and let those rows
say muted_foreground, which is what they meant and what their icons
already used.
tty7 detects the installed shells and lists them on the new-tab button,
but the Program field asked you to type one from memory: the same choice
was a menu in one place and a blind text field in the other. Put the
inventory behind a chevron inside the field — login shell, then every
detected shell, with a tick on the one in force. The field stays
editable, because a shell tty7 did not find still has to be reachable by
path.
slider_thumb was left at gpui-component's primary_foreground, which on a
dark theme paints a black disc on a dark page while the switch beside it
carries a light one. Both are the same handle; use the same colour.
Settings speaks in sentence case — every row label, description, section
header and segmented option — but one segmented option said "When
Unfocused", and the Updates section carried "Check Now" and "Cancel
Download" beside "Restore all defaults" and "Restart server…". A single
button even flipped from "Check Now" to "Checking for updates…" while
it worked. The menu bar and the palette keep title case.
Four update strings called it a service — including a "Restart Service"
button that fires the same action, on the same page, as "Restart
server…" two sections below. Every other line in the app, the menu bar
and the README say server. Fold the two buttons into one string and say
server in English, Chinese and Japanese.
How shells work, Updates and Server were hand-rolled headings — text_sm
at medium weight — so the one page a new user is most likely to open
looked like a different app from Terminal or Window & Tabs. Run them
through section_header.
It was the one hand-rolled row on the page: a quieter heading than every
other section, a full-width description, and the only switch in Settings
standing to the left of its own label. It was also the one row the
settings search could neither highlight nor dim, so searching "cli"
badged Agents with (2) and showed one match. Run it through
section_header and settings_row, and point the index entry at the label
the row actually renders.
Every icon-only control in the chrome names itself on hover — except the
settings close, the two SSH ellipsis menus, the switcher row menu, and
the tab close in both the strip and the sidebar. Same glyphs, same jobs,
same words as the buttons beside them.
slider_bar falls back to tokens.primary, the near-black we give primary
buttons, so on one settings page a set slider and an on switch disagreed
about what a set value looks like. Both are the same statement; give
them the same colour.
A section header carried px(11) while a row is a ListItem inset by mx(5)
whose own padding is px_3, so every label under a header started 6px to
its right. Give the header the sum, from named constants so the two
cannot drift apart again.
5423301 gave `settings_row` `flex_wrap()` and swapped the label column's
`min_w_0` for `flex_1` + a 200px floor, so the SSH host form would stack
instead of overflowing in a narrow pane. On every other settings page that
stopped long descriptions wrapping at all: the text ran under its own
control and out past the row it belongs to, which is plainly worse than
the case it was meant to fix.
The row goes back to what it was. `#ssh-detail` keeps its `min_w_0` — that
half of the commit is what actually fixed the 1200px overflow, and it is
independent of the row.
The SSH form still degrades below roughly 1000px, where the label column
squeezes to a word per line. That is the real remaining bug and it needs a
different answer than wrapping this row.
The key column was a hardcoded 46px, which fits "cwd" and "shell" and
little else. In English "changes" folded mid-word into "change / s". In
Chinese and Japanese almost every label wrapped — ja "作業ディレクトリ" is
eight glyphs in a column sized for three ASCII characters.
It now measures the labels actually on screen through the text system and
takes the widest, clamped to 46–108px so one long Japanese label cannot
eat the panel. The labels are `whitespace_nowrap`, so anything past the
clamp runs into the gap rather than folding.
Checked on screen in English and Chinese: "changes" and "工作目录" each sit
on one line, and the values still line up.
Search matches were painted as the terminal's selection tint at 0.32
alpha. That tint is itself only `mix(bg, fg, 0.24)`, so the blend landed
about 1.17:1 against the background — a 7% shift, in every theme. On the
built-in Light theme the matches were a barely-there grey smudge on white;
on dark ones only the glyph colour gave them away. "3/3" told you there
were three, and you still had to hunt for them.
The wash is now opaque and solved for a contrast ratio instead of a fixed
alpha, so it keeps the same weight whatever the theme's two colours are:
| | Before | After |
|---|---|---|
| Other matches | fixed 0.32 alpha ≈ 1.17:1 | `MATCH_WASH` = 1.45:1 |
| Current match | fixed 0.85 alpha | `CURRENT_MATCH_WASH` = 2.1:1, plus the caret-coloured border it already had |
`presets::wash(surface, tint, target)` bisects the blend the same way
`bisect_contrast` walks a colour, and falls back to `legible_ink` for a
theme whose selection colour is so close to its background that no opacity
reaches the target.
These are non-text ratios on purpose — enough to spot the block, not so
much that the glyph on top of it stops reading.
Verified on screen in both built-in themes: all three matches are now
findable at a glance, and the current one still stands out from the other
two.
At 1200x800 — an ordinary window size — the SSH host form was cut off by
the window edge: the Connect button was sliced in half, the Name, Host and
User fields had no visible right edge, and the Auth control lost its last
option entirely. "2FA" was off screen, so at that size an authentication
method could not be selected at all. Nothing scrolled horizontally, so
there was no way to reach any of it short of resizing the window.
Two causes, both in the layout:
- `#ssh-detail` is `flex_1` but had no `min_w_0`. Flex items default to
`min-width: auto`, so the column sized itself to its content instead of
to the space left over after the 280px host list, and the overflow went
past the window.
- `settings_row` then had no way to cope: the label column could shrink to
nothing while the control could not shrink at all. Adding `min_w_0`
alone made it worse — at 900px the labels collapsed to one word, then
one *letter*, per line.
The row now wraps and the label column has a 200px floor, which is the
usual settings-pane behaviour: side by side while there is room, control
stacked beneath the label when there is not.
| Window | Before | After |
|---|---|---|
| 1200px | Connect halved, three fields and "2FA" off screen | everything fits side by side |
| 900px | same, worse | rows stack; all six auth methods reachable |
| wide | fine | unchanged — the 720px cap still holds |
Checked on screen at 1200 and 900, and on Appearance and Terminal at 1200
to confirm the wrap does not fire where there is room: those two pages are
pixel-identical to before.
Every row with an edit variant — quick-connect and every saved SSH profile
— advertised "→ edit", and neither of the two gestures behind it worked:
- `→` never reaches the palette. gpui-component's `Input` binds bare
`right` to MoveRight inside its own key context, and the query field has
focus the whole time the palette is open, so the keystroke is consumed
before the scrim's handler sees it.
- `⌘↵` never reaches it either. The app binds `secondary-enter` to
ToggleFullscreen, which matches first — pressing it in the palette put
the window into fullscreen.
So a saved profile could not be opened for editing from the palette at
all, which is the only row-scoped way in; Settings → SSH lists them, but
loses which row you were on.
The chord is now `secondary-e`, claimed by neither the input nor the app.
It lives in one `EDIT_GESTURE` constant next to the matcher that reads it,
and the badge renders through `key_tokens`, so it says ⌘E on macOS and
Ctrl E elsewhere rather than a hardcoded arrow baked into three locale
tables. `EditHint` is now just the word ("edit" / "编辑" / "編集").
Verified on screen: the badge reads "edit ⌘E", and ⌘E on a quick-connect
row opens the SSH profile form with the host, user and port already filled
in from the query.
`ListState::new` starts with no selected row, and it only picks one when a
query *changes*. So opening the palette and pressing Return — the shortest
path through the feature — did nothing at all, and until the user typed or
pressed Down there was no row showing what Return was aimed at.
The delegate did think row 0 was selected (`selected: Some(IndexPath::
default())`), which is why this never showed up as a missing-state bug:
`selected_command()` answered correctly, but the highlight and the Confirm
event both come from `ListState`, which disagreed.
`build_list_with_delegate` now hands the list the delegate's own
`first_row()` — the one that skips empty sections — right after
construction, so all three palettes (root, plain, ssh-connect) open armed.
Verified on screen: ⌘P now highlights "New Tab" immediately, and Return
opens a tab instead of doing nothing.
The find bar floats over the top of the grid — `.absolute().top_2()`, 34px
tall — but `scroll_match_into_view` compared the match's line against the
whole viewport. A match in the first two or three rows was "on screen", so
nothing scrolled, and ⌘G would say 1/3 with match 1 hidden behind the
search field. Wrapping past the last match landed there every time.
Those rows now count as off screen. When a match is above the readable
band the view walks back just far enough to put it on the first clear row,
rather than calling `scroll_to_point`, which parks it on row 0 — the row
most likely to be covered.
The row count comes from the bar's own geometry, so it follows the font
size: 3 rows at the default 17px line height, 1 at 42px. `BAR_TOP` and
`BAR_HEIGHT` now live next to the `.top()` and `.h()` that render it, so
the two cannot drift apart.
Verified on screen: with three matches and the viewport at the live end,
⌘F then Return used to leave 1/3 behind the bar; it now scrolls so the
outlined match sits directly below it.
The file tree and the Session panel both list whatever host the workspace
spawns on, and every other action they offer goes through that host. Only
"Reveal in Finder" does not: `cx.reveal_path` talks to the local file
manager, so in a remote workspace it was handed a path that lives on the
other machine. Finder either does nothing at all — a menu item that
silently no-ops — or, if a local path happens to collide, opens the wrong
directory.
Both are now hidden unless the paths really are local:
| | Gate |
|---|---|
| File tree context menu | `spawn_host(cx).is_local()`, the same predicate `guard_local_spawn` uses |
| Session panel button | `view.local_cwd().is_some()` — a helper that already existed for exactly this (`remote_context().is_none() && host_id.is_local()`) and that this call site was not using |
Copy Path stays in both places: a remote path is still worth copying.
Checked on screen that the local file tree keeps the item and its
separators. The remote branch is reasoned from the predicate, not
exercised — it needs a real SSH host.
Every confirmation dialog in the app was built as `&[Cancel, Delete]`, and
gpui hands answer 0 to the platform first — which NSAlert draws on the
*right* and gives Return. So the app shipped 14 dialogs with the buttons
mirrored: Delete sat on the left, exactly where a decade of macOS has
trained people to expect Cancel, and Cancel sat on the right holding the
default.
Escape was worse: it did nothing at all, anywhere. gpui only sets the
Escape key equivalent on a `PromptButton::cancel`, and every call site
passed plain strings, which become `PromptButton::Other`. There was no way
to dismiss any of these dialogs from the keyboard except by pressing
Return.
A shared `ui::confirm_answers(action, keep)` now builds the pair, so the
arrangement is decided in one place:
| | Before | After |
|---|---|---|
| Right button (Return) | Cancel | the action |
| Left button | the action | Cancel |
| Escape | nothing | Cancel |
| Space / initial focus | Cancel | Cancel |
| `Ok(1)` means | act | — |
| `Ok(0)` means | cancel | act |
Verified on the file-tree delete, end to end: Escape leaves both files in
place, Return removes only the one that was right-clicked. Also checked on
the ⌘W busy-pane guard, where Escape and Space keep the tab and Return
closes it.
Two prompts keep their own shape and say why in a comment:
- The daemon-mismatch prompt at launch offers Quit and Restart Server and
nothing else. With no answer that leaves things alone there is nothing
safe to give Escape, so Quit stays on Return — it loses no sessions,
while restarting the server ends every one of them.
- The unsaved-editor prompt has three answers. Save keeps answer 0, Cancel
is marked so it takes Escape, and Discard sits on the far left where
nothing lands by reflex. gpui puts the initial keyboard focus on Discard
so it stays reachable without a mouse.
f8e2a59 added `OpenInFileManagerFailed` in all three locales for exactly
this case and then only wired its sibling, so the key sat dead — clippy
was reporting it as never constructed. The follow-up fix invented a second
key rather than using it.
Now the notification reads "Could not open /…/themes", naming the path
instead of a category, and `ThemeFolderFailed` is gone. Clippy's
never-constructed list is back to what it is on main.
`open_themes_folder` created the directory with `let _ =` and then handed
the path to the system file manager regardless. On the one click that can
actually fail — the first one on a fresh install, where the folder does
not exist yet — a read-only or permission-denied config directory left the
button doing nothing at all, with no message.
It now reports the failure through the same `notify_err` path as the rest
of the theme editor, and stops instead of opening a path that is not there.
The `themes_dir() == None` branch stays a log line: without a config
directory there is no theme editor to have clicked from.