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.
The link modifier was `mods.platform`, which gpui maps to ⌘ on macOS but
to the Win/Super key elsewhere — a key the OS mostly swallows, so on
Windows and Linux neither the hover underline nor click-to-open could be
triggered at all. `config.json`'s own docs already promised "⌘/Ctrl-click";
this is the implementation catching up.
Use `Modifiers::secondary()` (⌘ on macOS, Ctrl elsewhere) at all three
sites: the click handler, the hover probe, and the app-level modifier
tracking that pushes the state down to background panes. Not
`platform || control` — that would steal ⌃-click on macOS, where it means
"right click".
Ctrl+click still falls through to mouse-tracking TUIs when there's no
link under the cursor, since `open_link_at` reports whether it consumed
the click.
Fixes#183
Option-as-Meta had no effect for anyone typing with a CJK input source.
The whole setting was dead for them: with Pinyin selected, macOS reports
Option chords as printable text (Option+B composes the special
character), so gpui routed them to the IME before the key handler ran.
The IME committed the composed character and swallowed the event --
on_key_down never ran, and reshape_option_keystroke never got a say.
Switching to ABC made it work again, which is why this looked
intermittent. Verified on the wire: with Pinyin active Option+F/Option+B
put c692 / e288ab on the PTY where ESC f / ESC b belong.
The routing decision lives in gpui's macOS backend and is asked once per
view, with no keystroke in hand, so it could not answer "IME for text,
but not for this chord". gpui now comes from our fork, whose one commit
passes the keystroke to prefers_ime_for_printable_keys; the default
implementation ignores it, so no existing handler changes behavior. The
terminal answers per key: an Option chord with the setting on stays on
the dispatch path, everything else still prefers the IME.
Gated on the setting deliberately. With Option-as-Meta off the chord is
text input and the IME is the right owner -- it is what makes dead keys
(Option+E then E -> e-acute) compose at all.
The fork is wired in with [patch] on the source rather than by editing
the gpui pins, because gpui-component declares its own gpui from the
upstream URL and a pin swap would put two incompatible copies of gpui in
the tree. Fetching a repo that size needs the git CLI; cargo's built-in
libgit2 transfer times out partway through.
Fixes#177
All 17 tty7-drawn glyphs are redrawn to a single spec: stroke 2.1, round
caps and joins, radius 3.4-4.4, a near-square 3.4->20.6 box, and filled
nodes at r >= 1.6. Metaphors stay conventional throughout - a magnifier
is a magnifier, a folder is a folder - so nothing needs relearning; what
changed is how they are drawn.
The previous set mixed weights (1.9 with a 2.4 exception for plus), left
folder and folder-closed resolving to one identical drawing, and mixed
terminal treatments across the row. This one holds every glyph to the
same five rules: optical rather than geometric sizing, handles meeting
circles on the tangent, even interior spacing, one terminal treatment,
and equal apparent area rather than equal bounds.
Two notes for future edits, both in the module docs: nothing here relies
on partial alpha surviving rasterisation (the dock block is a flat fill),
and folder-closed carries an inner rule so it stops sharing art with
folder.
The eye tile spent the panel's scarcest row — the header the tab tiles now
share — on an option that gets set once and then forgotten. It moves into
the menu the tree rows already have, as a row under "Reveal in Finder"
whose label says what the click will do ("Show Dotfiles" / "Hide Dotfiles")
rather than checking off the current state, since one checked item makes
`PopupMenu` reserve an icon gutter on every row in the menu.
The SFTP browser's header keeps its own controls: Refresh and the "⋯" menu
are actions on a live connection, not a view option.
The rail's toggle has never lit up when the rail is out, and for the same
reason the detail panel's shouldn't: the panel being open is already on
screen — it *is* the panel — so a selected capsule only restates it, and
with one toggle lit and the other not, a chrome tile's fill stopped meaning
anything in particular. The state moves to the tooltip's verb (Show / Hide
Detail Panel), which is what the rail's toggle already does.
Off macOS the detail panel was a full-height column beside the title bar,
so the bar — which lays out the ─ ▢ ✕ group at *its* own right end — ended
before the panel did, stranding the window controls mid-window with the
panel's grey to their right. On macOS the same layout is right: the traffic
lights are on the left, over the rail, and nothing collides.
So off macOS the bar now spans the panel too, reaching the real top-right
corner, and the panel hangs below it:
- The caption row over the panel is painted in the panel's own surface, so
the column still reads as one continuous sidebar from the top of the
window rather than starting 40px down in a different colour.
- The corner chrome (detail-panel toggle, workspace chip) stays in the
strip, but over the panel it aligns with the column it sits on — pinned
to the panel's leading inset instead of hugging the controls.
- The panel's tab tiles no longer earn a row of their own. Under a caption
row that already carries chrome they made three stacked headers before a
single line of content, so they move into the section header the panel
was drawing anyway: name and count at one end, the four tiles at the
other, a hairline under it. A tab's own control (Files' dotfile toggle)
still sits on that line, ahead of the tiles.
macOS keeps the full-height panel column with its own title-bar-height top
zone, drag region and corner chrome, unchanged.
Verified on Windows 11 in both tab-bar modes, panel open and closed, and
with the code overlay open (which now stops under the bar, so the controls
stay clickable and its header lines up with the panel's).
On Linux, `"theme_follow_system": true` made the app panic on launch with
`RefCell already borrowed` from gpui's Wayland/X11 client, on both backends.
Once a user turned "sync with system" on, the setting persisted and every
subsequent launch died — a hard brick until `config.json` was hand-edited back.
gpui's Linux backends dispatch a window's appearance-changed callback while the
platform client's `RefCell` is already mutably borrowed: both XDP handlers hold
`client.borrow_mut()` across `set_appearance`, which invokes the callback
synchronously. `cx.window_appearance()` re-borrows that same cell.
Our appearance observer called straight into `apply_theme` →
`effective_preset_id` → `system_dark` → `cx.window_appearance()`, so it
re-entered that borrow and panicked. Only while following the system: with the
setting off the observer returns before reading the appearance, which is why
the default install was fine. It reproduced on *every* launch because the XDG
portal source emits one appearance event during startup.
Cache the OS appearance in a `SystemAppearance` global instead. The observer
fills it from `Window::appearance()` — the window's own cell, released before
the callback runs — and `system_dark` reads the cache, so no platform read
happens on the re-entrant path. macOS still re-seeds the cache from the
platform right after `apply_theme` releases the native appearance pin, since
the window reports the pin rather than the OS setting until then.
Zed keeps a `SystemAppearance` global for the same reason, and gpui-component
switched to `window.appearance()` over the same Linux panic
(longbridge/gpui-component#104).
Fixes#179
Enabling `kitty_keyboard` (#184) made an upstream `alacritty_terminal` bug
reachable from any foreground program. `push_keyboard_mode` caps its stack by
removing from `title_stack` instead of `keyboard_mode_stack` -- a copy-paste
slip from `push_title` that compiles because both are `Vec`s and the removed
value only feeds a `trace!`.
Two consequences. Each overflowing push silently drops a saved window title, so
a later XTPOPTITLE restores the wrong one. And once the title stack is empty,
`Vec::remove(0)` panics: 4097 unpopped `CSI > 1 u` pushes -- roughly 20KB of
output -- kill the `tty7-remote-reader` thread and freeze the pane. The depth
cap never trimmed `keyboard_mode_stack` at all, so it was doing nothing.
Hostile output is not required. A TUI that pushes without popping (per redraw,
per keypress) reaches 4096 on its own in a long session.
Pin our fork of Zed's fork instead: `tty7` is Zed's `fcf32fe` plus the one-word
fix. No `[patch]` section is needed -- tty7 is the only crate in the tree that
depends on `alacritty_terminal`, so a plain URL/rev swap cannot split it into
two incompatible copies the way the gpui pin would.
The regression test guards the pin rather than our own code: it pushes past the
4096 cap and then queries, using the reply as a liveness probe. Verified to fail
against the unpatched rev.
Still present on alacritty master as of 852e971. Drop the fork once it lands
upstream.
The stateful palette titles (Hide Left Sidebar / Show Right Panel) read
`cfg.sidebar_collapsed` and `cfg.right_panel_visible`, but both of those are
per-window state living on `Tty7App` -- the config copies only record whichever
window toggled them last. With two windows in different states the palette in
one of them named the other's rail: the row read "Show Left Sidebar" while the
rail was out, and clicking it hid it.
Pass the calling window's own state in as a `ChromeState` instead. Deliberately
not `left_panel_open()`: that also folds in `!tabs.is_empty()`, and on the home
page the command still flips the collapse flag, so the title has to describe
the flag rather than what is currently on screen.
The tab bar's side stays on `Config` -- that one really is app-wide.
The GUI-launch locale fallback exported a literal `LC_CTYPE=UTF-8`. That
name is a BSD libc alias with no glibc equivalent, and the stock
`ssh_config` ships `SendEnv LANG LC_*`, so it rode along to every host we
ssh into. There `LC_CTYPE` outranks the `LANG` the host sets for itself
and then fails to load, dropping the remote shell to the C locale --
re-creating the mangled non-ASCII output the fallback exists to prevent.
Derive the locale from the system locale instead, the way Terminal.app
and iTerm2 do: reduce the CFLocale identifier to its POSIX `lang_REGION`
stem, and fall back to `C.UTF-8` then `en_US.UTF-8`. Every candidate is
checked against `/usr/share/locale` before it is exported, so tty7 never
hands a shell a name the C library cannot load -- including on machines
whose region combination has no installed locale (`en_CN` is an ordinary
macOS setting that resolves to no locale at all).
Still `LC_CTYPE` only, and still only when no locale is inherited or
configured, so message/date/number localization and explicit user
overrides are untouched.
Fixes#178
Chrome tiles painted every glyph at full `foreground` (#111 on white) —
the darkest ink in the window sitting on its lightest surface, a full step
darker than the tab rail's inactive rows and two steps darker than every
other secondary icon. The title bar read as a hard black row against the
soft greys around it.
Idle tiles now take `sidebar_foreground`; a selected tile keeps full
`foreground`, so "on" is carried by darker ink as well as the filled
capsule instead of the capsule alone.
The menu bar shipped four menus in the order App / Shell / Window / View
with no Edit menu at all, so Copy and Paste existed nowhere but a
right-click, and About / Check for Updates / Hide / Minimize / Help had
no home. It now follows the macOS HIG's standard set.
The palette listed 47 commands in three competing naming styles, ranked
only by catalog order, with no grouping and no way to reach most of what
the tab context menu could do. It now has one documented grammar, a
scored fuzzy ranker, group bands with a frecency-driven Recent, and the
commands it was missing.
Settings had a three-row Shell page indistinguishable from Terminal, a
seven-group Terminal page that had become a junk drawer, two different
groups called "Window", and a search index that had drifted so far from
the rendered rows that "opacity" and "completion" returned nothing.
Also folds copy / cut / paste / undo onto one code path each, which fixed
two real drift bugs: the right-click Paste skipped the image-paste branch
that Cmd+V had, and Copy rendered disabled whenever the selection was in
the prompt editor rather than the grid.
Grok was drawing the generic robot glyph on a slate disc — the fallback
picked back when no usable mark was bundled.
xAI publishes its symbol only as a ~2:1 landscape lockup that bleeds off
its own canvas; traced and fitted to a 24x24 box it is unreadable as a
16px silhouette, which is what the tab chip and sidebar render. So the
bundled mark is lobehub/lobe-icons' square transcription (MIT), drawn for
exactly this avatar use. Its notice rides in the SVG.
The slate accent goes with it. That mid-tone exists for vendors whose
monochrome mark is grey or a gradient (Cursor), because a white field
vanishes on a light theme; a black field has no such problem — it stays
darker than even the darkest theme background and the white mark carries
the badge. Grok brands in black, like Codex, so it keeps that.
Adds a guard test: every CLIAgent::icon_path must resolve through the
asset source. A brand mark means touching two files, and forgetting the
registration costs the agent its avatar silently.
Grok Build exposes a Claude Code-shaped hook surface, so tty7 can now
install into it and give grok panes live session status and
resume-after-restart, not just a brand chip.
- Owned hook file at ~/.grok/hooks/tty7.json (grok loads every JSON file
there; global hooks need no folder-trust grant), so the user's own
hooks are never touched.
- Read camelCase payload keys: grok's envelope sends sessionId, and
without it restore loses the id --resume needs.
- Relabel events that arrive through grok's Claude-compat scan of
~/.claude/settings.json, keyed on the GROK_HOOK_EVENT var its hook
runner injects — otherwise a grok pane reports Claude Code, and having
both integrations installed emits every turn under two identities.
- Resume via `grok --resume <id>`, stripping the flags that would fight
the injected id (--resume/--load/--continue/--session-id/--fork-session)
or relocate the session (--worktree/--worktree-ref).
Notification is subscribed with a matcher for elicitation_dialog only.
Grok dispatches its permission_prompt notification before the permission
system decides, so it fires on essentially every tool call, auto-approved
ones included; escalating that to the amber "needs you" state would flash
the pane and fire a desktop notification on every tool a turn runs.
The four platform jobs each ran softprops/action-gh-release, so the first
one to finish published a release carrying only its own assets. That
release immediately became /releases/latest, which the in-app update check
polls — users were prompted to download a version whose assets were still
being built, and macOS users in particular could open the page minutes
before a .dmg existed. A permanently failed platform left the gap forever.
Build jobs now hand their bundles to a single draft-release job via
upload-artifact. It runs only after all four succeed, and assembles a
draft: drafts are invisible to /releases/latest, so nothing is advertised
until the release skill has verified the six assets, written the notes,
and published it by hand.
This mirrors the shape nightly.yml already used.
CHANGELOG: keep both Unreleased sets, with the history-search entry under
Added beside the multi-window ones and the Ctrl+J/M fix in its own Fixed
section.
Review follow-ups on top of the multi-window work.
- A brand-new workspace came up on the home page with no shell, because
`claim` always hands back an (empty) session and the window treated that
as "restore this". A first run and `New Workspace` now take the
first-run path again and spawn a terminal; the launch that exists to
show the workspace picker asks for an empty window explicitly
(`FreshStart`).
- The close-window prompt promised sessions "will be restored the next
time you open tty7", which is no longer what happens — the workspace
detaches and waits in the picker. Both it and the one-time detach hint
now point at the title bar's workspace menu rather than the macOS
Window menu, which does not exist on Windows or Linux.
- `ToggleSftp` read the panel state off the config, which is now only
what a *new* window starts with; it reads this window's own state.
- `SelectWorkspace1..9` were unbindable: registered as actions but absent
from the keymap tables. Added with no default chord (⌘1–9 is the tab
row's).
- `theme_commands`' doc comment had been captured by a function inserted
above it, and the Window menu's slot→action mapping was a second copy
of the title-bar chip's.
- CHANGELOG: drop the ⌘1–9 claim (no such binding ships), and document
the chrome tile sizing that rode along with this branch.
The local command editor consumed every Ctrl chord at the prompt, matched
or not, so two things the shell owns quietly stopped working (#163).
^J and ^M carry accept-line's control codes — Enter by another name — but
fell into `apply_readline_ctrl`'s no-op arm, so the keys did nothing at
all. Route them through the same path Enter takes, via a shared
`accept_line`, so the completion picker and the history menu treat them
identically.
^R was recognized, but only ever opened tty7's own history menu, with no
way back to a `bindkey`ed widget (fzf, percol). Add `history_search`
(default on, Settings → Terminal → Keyboard): with it off, the edited
line is handed to the shell and the raw ^R follows it, so whatever is
bound there answers. The "shell integration never engaged" notice stays
quiet in that case — ^R reaching the PTY is then the point, not a gap.
`handoff_tab_to_shell` generalizes to `handoff_line_to_shell(chord)` to
carry the ^R handoff; the Tab path is a thin wrapper over it and its
behavior is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict in the sidebar's control row: main wrapped both tiles in
`occlude()` so Windows' HTCAPTION drag doesn't swallow their clicks, while
this branch moved their geometry onto `chrome_tile_sized` / the shared
`TILE_*` constants. Kept both — occluded wrappers around helper-sized tiles.
tty7 had exactly one window, so `main` opened it inline and every app-wide
duty — tray, menus, the quit hook — lived in `Tty7App`'s constructor. This
splits those apart: a *workspace* is the persistent identity (tabs, splits,
cwds, name) and a *window* is a transient view onto exactly one of them.
- `ui::windows` — the app-level window registry and the single place that
opens a window. Exactly one window per workspace is enforced there: the
daemon gives each pane a single subscriber, so a second window on the same
panes would silently steal the first's output. `open` focuses the existing
window instead. New windows cascade so one never lands on top of another.
- `WorkspaceStore` owns session.json, so windows never race each other as
writers. Closing a window *detaches* — panes keep running in the daemon
and the entry stays for the picker; `StopWorkspace` kills the sessions and
keeps the layout; `DeleteWorkspace` also forgets it.
- Window menu lists every workspace with a monogram badge and a liveness
dot, ⌘1–9 for the first nine. Same list in the palette; closed ones also
appear in a home-page picker with a coarse relative age.
- Sidebar collapse and right-panel visibility move onto `Tty7App`, so
toggling one window's chrome leaves the others alone; the config value
becomes what a new window starts with. Panel *width* stays shared — a
width is a preference, not a view state.
- Tray, menus, and the quit hook now walk the registry rather than
belonging to a single window.
Protocol goes to v2: `RemoteKind::Wsl` is a new enum variant, which is not
the additive change it looks like — the enums carry no `#[serde(other)]`, so
a v1 peer fails the whole decode and drops the pane's connection. The
handshake now catches that skew and offers a restart.
ToggleSftp only ever opened: it set the detail panel to Files and stopped,
so a key bound to it was a dead press once you were already there. Pressing
it on Files now closes the panel, flipping the same preference
toggle_right_panel does.
The remote browser's 500ms transfer poll also ended only when the browser
did, and the browser was retired solely by render_right_panel. That made a
loop holding a daemon round-trip and a full re-render depend on a caller it
can't see being invoked every frame. It now makes the same closed-panel
check on its own tick and retires itself, so the render path is an
optimisation (a frame sooner) rather than the only thing standing between a
closed panel and a poll that runs forever.
Three follow-ups to the detail-panel move:
- The browser's 500ms transfer poll only ends when the browser does, and
render_right_panel bailed on a closed panel *before* the retire check —
so closing the panel left a daemon round-trip and a full app re-render
running twice a second for a column nobody can see. Retire first, and
treat a closed panel as "not looking at that pane".
- The procs poll captured its forwards flag for the life of the loop.
That flag flips without a pane switch — a native-SSH pane you are
already watching on Info finishes connecting — and neither the pane id
nor the generation changes, so the loop never started listing. Record
it on the panel state and re-read it on each reschedule.
- Clear the job list when the browser closes, so the footer doesn't
report the previous pane's transfers until the new pane's first poll
lands.