Files
tty7/CHANGELOG.md
T
2026-09-29 12:58:21 +08:00

5974 lines
368 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [26.9.4] - 2026-09-29
### Added
- **The New Tab menu names the agents you actually run** (#955). Beside Local
and SSH, an Agents section lists up to three agents that have been launched
or seen running, most-used first — the same short list the shells get. A row
opens that agent in a new tab (hold ⌥ to split) and types its `agent_launch`
command, or the bare binary when none is set. Agents that are installed but
not in those rows are one click away: **Other Agents…** opens Search
Everywhere already filtered to `agent`. The section is absent when this
machine has no agent to offer. **New Agent Tab** (⌘⇧A) is unchanged: it
still starts the one used last.
- **Find in files, from the right panel's Files tab.** Its field now matches
file names and file contents at once, in two sections. Type and the active
tab's project — the same roots the tree shows — is searched as you go, with
content hits grouped by file, a count per file, and each match highlighted in
its line. Click a hit to open the file in the built-in editor at that line
and column; Enter searches again. Toggles for match case, whole word and
regular expressions sit at the end of the field. The walk honours
`.gitignore` with or without a repository, skips dot-directories, binary
files and files over 1 MB, and says so when a cap (2,000 lines, 100 per
file, 20,000 files, ten seconds) cut it short. In a remote workspace the
search runs on the remote machine through `tty7-server`; a server that
predates it is never sent the request — the tab asks for the server to be
updated instead of showing an empty result.
- **A GitHub tab in the right panel: issues and pull requests, read-only.** It
follows the focused pane's repository, binds to its `github.com` remote
(`upstream` over `origin` in a fork, with a menu to pick another), and lists
open or closed issues or pull requests with state glyphs, labels and
relative times; a label click filters by it. A row opens the detail —
description and comments as Markdown, and for a pull request its branches,
size and changed files, each of which opens its patch in the diff overlay.
Sign-in reuses the GitHub CLI (`GH_TOKEN`, `GITHUB_TOKEN`, then
`gh auth token`, found even from a Finder launch); public repositories work
signed out, and a private repository or a spent rate limit says to run
`gh auth login`. Screenshots pasted into an issue are shown; images from
any other host are shown as links rather than loaded, and non-web link
targets are disabled. The tab talks to `api.github.com` only while you use
it.
- **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`).
The tab moves one slot past its neighbour — the keyboard form of dragging it
in the tab strip or the sidebar — and wraps past either end, so one held key
walks a tab the whole way down the list. On a left tab bar the same pair
reads as up and down, and the tab stays inside its sidebar group, wrapping
at the group's ends. Shipped unbound like the pane-swap pair: bind it under
Settings → Keyboard shortcuts ("Move Tab Left" / "Move Tab Right"), in
`config.json`, or run it from the palette.
- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7
recognises whose binary is on `PATH` is a palette command — "Agent: Claude
Code", "Agent: Codex", … — ordered by how often and how recently it was
launched or seen running, and each is bindable as `LaunchAgent:<slug>`. "New
Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is
select-all) starts the one used last. A launch always opens a new tab in the
current tab's directory and types the agent's command into its shell once the
pane exists, never into a pane that was already there, so detection, status
and resume work as for a hand-typed agent and quitting it returns to the
shell. The command is the agent's bare binary unless `agent_launch` in
`config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`);
a wrapper named there is detected as that agent without an `agent_commands`
entry, including a script run under its interpreter, and the daemon picks up
an edit without a restart. A running agent's pane menu has "Set Current Launch
Args as Default", which writes the flags it was started with — minus the
session it resumed and any prompt — into `agent_launch`. A remote workspace
cannot be asked for its `PATH`, so there the list is the agents already seen
running in that workspace.
- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a
tab → Hibernate, or "Hibernate Tab" in the command palette, stops every
process in the tab to give its memory back while the tab keeps its place in
the sidebar, faded and marked with a moon. Selecting it wakes it — or
right-click → Wake to warm it up without switching. Waking is the restore a
reboot already runs: each pane comes back in its old directory and shell,
opens on the screen it left, and a supported agent with a captured session
is resumed. A sleeping tab stays asleep across app and daemon restarts; the
tab on screen is always awake, so the last awake tab cannot be put to sleep.
`tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane
ls --json` carry a `hibernated` flag. Offered only where the machine's server
can do it (this build or newer).
- **A port forward can be switched off without losing its rule** (#439). A
forward could only be removed, so pointing one local port at a different
remote target meant deleting the rule and typing the other one in again. Each
forward in the Ports section now has a switch: off releases the listener and
keeps the rule in the list, on binds it again from the rule it was made from.
Switching one on while another switched-on forward holds its port is refused
with a notice naming that forward, rather than failing at bind time. A switch
flipped in the panel on a rule that came from a saved host is written back to
that host, so the next connection opens the same set; a rule saved switched
off is listed but not opened. Profiles saved before this load with every rule
on.
- **Windows file paths are links** (#965). `C:\Users\me\a.png`,
`c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any
other file path, with a `:10:2`, `(10,2)` or `#L10` location kept. The path
is found when Chinese prose is glued straight onto it
(`图片已保存到:c:/Users/me/a.png`) and inside a Markdown link
(`![chart](c:/out/chart.png)`), and is handed on with backslashes so
Explorer opens and reveals it — a forward-slashed path used to open
Documents instead. In a WSL pane a drive path is looked up under
`/mnt/<drive>`. A letter and a colon alone (`a:b`, `C:`, `C:notes.txt`) is
never read as a drive.
- **A quoted path may contain spaces** (#965). `"C:\Program Files\app\app.exe"`,
`'/Users/me/My Docs/a.txt'` and the same in backticks are one link, with a
`:10:2` or `(10,2)` location read inside the quotes or just after the
closing one. Only quotes do this: an unquoted space still ends a path, and
quoted prose that is not written like a path — no separator, a space at
either end, over 260 characters — is left alone.
- **"Open with Default App" in a file link's right-click menu** (#965). It
hands the file to whatever the OS has it associated with, whatever
`link_file_open` makes "Open" do. Shown for files on this machine only, and
not when "Open" already uses the system opener.
- **The code editor talks to language servers** (#1002). Opening a local file
starts the server for its language — rust-analyzer,
typescript-language-server, pyright (or pylsp), gopls or clangd, whichever
is on `PATH` — one per project root, shared by every buffer under it.
Diagnostics are underlined with their message on hover and counted in the
status bar, and a Problems list (⌘⇧M, Ctrl+Shift+M elsewhere) gathers the
errors and warnings of every open file. Completion, hover, signature help,
go to definition (F12 or ⌘-click), Find All References (⇧F12), code actions
(⌘.), Format Document (⇧⌥F) and Rename Symbol (F2) work across files; the
keys are bound inside the editor only, so terminal programs keep F2 and F12.
A server missing from `PATH` costs only its language, with a one-line hint
in the status bar; a server whose last file closed shuts down after a grace
period, and one that crashes is restarted a few times. Files on a remote
workspace get no server. `editor_lsp` turns it all off.
- **Multiple cursors, git change markers, symbols and a split in the code
editor** (#1002). ⌘D, ⌘⇧L and ⌘⌥↑ / ⌘⌥↓ add cursors, and the status bar
counts the selections. The gutter marks lines added, modified or deleted
against the file's staged version, on remote workspaces too; ⌥F5 / ⇧⌥F5 step
through the changes, ⌥F3 or a click on a marker peeks one, and Revert Change
undoes it as one step (`editor_git_gutter` turns the markers off). Go to
Symbol (⌘⇧O) lists the file's outline, previewing each symbol as it is
arrowed through, and once a query is typed adds the language server's
matches from across the project; a breadcrumb row over the text shows the
path and the symbols around the caret. Back and forward (Ctrl+− / Ctrl+⇧+−
on macOS, Alt+← / Alt+→ elsewhere) walk a per-tab history of jumps. ⌘\
(Ctrl+\ elsewhere, bound only in the editor so the terminal keeps SIGQUIT)
opens the file in a second group on the right, saved with the tab. The
right-click menu gains Toggle Comment, Move, Duplicate and Delete Line and
Go to Matching Bracket, and case transforms, Trim Trailing Whitespace and
Join Lines are bindable commands. **Settings → General → Editor** holds the
change markers, language servers, soft wrap and rendered Markdown.
- **Quick open, Go to Line, New File and Save As in the code editor** (#984).
Go to File (⌘O on macOS, unbound elsewhere) finds any file in the active
tab's project by fuzzy name — on local, SSH and WSL workspaces alike,
skipping what the Files tree hides — and `name:line[:col]` jumps to that
spot. Go to Line is Ctrl+G. Save As uses the native panel locally and a
path bar on a remote workspace, and the files each tab has open are
remembered across restarts. Loading and saving keep a file's encoding
(UTF-8, GB18030, Windows-1252), byte-order mark and CRLF line endings as
they were, indentation is detected, `.editorconfig` is honoured, and the
status bar shows indentation, encoding and line ending.
- **A local file pasted or dropped into a remote pane is uploaded there**
(#1004). Copying a file in Finder and pasting it into an SSH pane, or
dropping it on one, pasted this machine's path, which the remote program
cannot open; only clipboard images were uploaded. Every local path handed to
a pane now takes one route: on an SSH host it is uploaded into a directory
of its own under `~/.cache/tty7/clipboard`, so it keeps its real name, and
the remote path is pasted; in a WSL pane it is rewritten to `/mnt/<drive>/…`;
locally it is pasted as before. Folders upload recursively, a large file
appears at its path only once complete, a transfer is given up only after
it stops making progress, and staged pastes older than a week are pruned.
Rows dragged from a remote Files tree are no longer taken for local files.
- **The GitHub tab shows a pull request's checks, reviews and merge state**
(#1000). The detail leads with the check runs and commit statuses on the
head commit, failures first, with durations and links to their logs; then
one row per reviewer; and a line under the branches such as "Waiting on 1
check", "Merge conflicts" or "Ready to merge". While a check is running the
checks are re-read every 20 seconds when signed in. The list pins the pull
request of the pane's branch under the repository row, found through the
branch's upstream so a fork's branch resolves under the fork's owner. Long
comment threads, descriptions, reviewer lists and file lists fold behind a
"show all" row.
- **The GitHub tab tries every signed-in `gh` account** (#987). Only gh's
active account was used, so a private repository owned by an organisation
that another signed-in account belongs to read as a 404. On a 404, 401 or
403 the tab now retries with gh's other `github.com` accounts and remembers,
per owner, the one that got through. A token from `GH_TOKEN` or
`GITHUB_TOKEN` is still used on its own.
- **A link a table wrapped inside its cell opens whole** (#1001). Markdown
tables — Claude Code's among them — wrap a long cell by hand, one bordered
row per line, so hovering or clicking a wrapped URL only ever got the
fragment on one row. A link now carries on from a row into the next when its
text reaches the cell's right border and resumes at the left one, with the
borders lined up; only `│`, `┃` and `║` count as borders, never a bare `|`.
The hover underline covers just the cell's text on each row.
- **Qoder CN CLI support** (by @ArnoChenFx in #988). The mainland-China build
of Qoder (`qodercn`, `qoderclicn`, `qoder-cn`) is recognised, reports its
status through hooks and has its past sessions listed, honouring
`QODERCN_CONFIG_DIR`.
- **Prime Agent and Empryo are recognised, and Antigravity reports its
status** (by @kalpakprod in #975). Prime Agent (`prime-agent`) resumes and
forks its sessions and reports status through the same extension bridge as
Pi. Empryo (`empryo`) is detected and resumed with `--session`; it gets no
status hooks yet, because it strips tty7's variables from hook processes.
Antigravity CLI (`agy`) gets a `tty7` hook set in
`~/.gemini/config/hooks.json`, beside the user's own, so its tab shows
working and done.
- **A working agent's status dot blinks.** It alternates every 600 ms between
its colour and a paler blend of it, so a column of tabs shows at a glance
which agents are still going. The repaints stop once no agent is working.
### Changed
- **Every pick-one setting is a dropdown.** Settings drew a single choice two
ways — a row of segmented buttons for most, a dropdown for the language, the
window backdrop and the rest — with nothing to say which got which, and the
segmented rows ran to whatever width their labels made, so the right-hand
column never lined up. All of them are dropdowns now, one width. A value set
by hand off the presets (scrollback, the notification threshold) shows as a
checked "Custom (N)" at the foot of the menu, as the extra segment did.
- **The mouse wheel no longer zooms the font by default.** ⌘ (Ctrl elsewhere)
plus the wheel used to resize the font, and ⌘ is held for so much else that
the text jumped size mid-scroll. `mouse_zoom_modifier` now defaults to
`none`; pick a modifier in Settings to have the wheel zoom again. ⌘+ / ⌘−
are unchanged.
- **The command palette is now Search Everywhere, with tabs.** <kbd>⌘ P</kbd>
(<kbd>Ctrl ⇧ P</kbd> elsewhere) opens one search over **All**, **Terminals**,
**Sessions**, **Hosts** and **Commands**, walked with <kbd>⇥</kbd> / <kbd>⇧
⇥</kbd>, keeping what is typed. **Terminals** lists every open tab of every
workspace, this window's most recently used first, and jumps to it wherever it
lives; the shells and agents it can open follow. **Hosts** holds the saved SSH
hosts and whatever address or `ssh …` command line is typed, which replaces
the separate input *SSH: Add Connection…* used to open. **All** shows the best
few rows of each, the tab with the best match first, and folds the rest into a
row that opens its tab; before anything is typed it leads each section with
its recent rows and tops it up to five from the rest, so a fresh window is not
empty. With nothing typed, <kbd>⌘ P</kbd> <kbd>⏎</kbd> goes back to the tab
you were just in. The keybinding action is still `TogglePalette`, so a custom
binding keeps working.
- **Resume a past agent session from Search Everywhere.** Its **Sessions** tab
lists past sessions of Claude Code, Codex, Gemini CLI, Qwen Code, Qoder,
CodeBuddy, Pi, Oh My Pi, Kimi Code, Copilot CLI, Droid, Cursor CLI and
OpenCode — those that ran in the focused tab's directory first — by the title
the agent gave them, with directory, branch and age, and <kbd>⏎</kbd>
resumes one in a new tab in the directory it ran in, with the agent's
configured launch flags. In a remote workspace the list is that machine's
sessions, and they resume there. <kbd>⌘ E</kbd> (<kbd>Ctrl E</kbd>
elsewhere) on a session opens what else can be done with it: fork it, for
the agents that can, copy its id, or *Remove from List*, which only hides it
from the tab (`hidden_agent_sessions` in `config.json`) and leaves the
agent's history alone. Only the ends of each transcript are read, in the
background, and remembered until the file changes; OpenCode's and Cursor's
SQLite stores are opened read-only. The control dialect moves to v12, so
each remote host needs one Update Server, which ends the sessions on it.
- **The command-line ghost suggests what you ran last, not what you ran
most.** It was the top prefix match by frecency, where run count and the
current-directory bonus outweighed recency, so after `git commit -m x`
typing `git c` still offered `git checkout main` because that had run 20
times here. The ghost is now the newest entry that extends the line,
preferring one run in the current directory and falling back to the newest
anywhere, and it skips commands whose last run exited non-zero, so it names
what ↑ recalls. Ctrl+R still ranks by frecency. Re-running a command now
moves it to the newest history entry instead of adding a second copy
(before, only an immediate repeat was collapsed), so ↑ steps onto each
command once, the same as after a restart.
- **The sidebar groups tabs by repo automatically; pin what you want to keep**
(#955). Groups now come in two halves. Below, every tab you have not pinned is
filed under its git repository, and an SSH tab under the host it is on rather
than its remote path — `/home/ubuntu` on two machines used to share a header.
Above sit the groups you keep, each marked with a pin beside its name, in the
order you drag them into, until you delete them. A pinned group can keep a
folder: a tab whose working directory enters it joins it (the deepest folder
wins when they nest, and a worktree of a pinned repo counts), while a tab you
drag out stays out until it leaves the folder and comes back. Pin an auto
group with the pin on its header or by dragging the header up among the pinned
ones; pin a folder by dropping it from Finder, with **Pin as Group** in the
Files panel, or with **Open Folder as Group…** in the palette. **New Group**
in the palette, on a tab's right-click or on a right-click in the empty
sidebar below the tabs makes a label group. Deleting a group closes nothing —
its tabs go back to auto grouping — and dragging a tab below the pinned groups
does the same for one tab. Groups, their order and which are folded are stored
with the workspace, so every window onto it agrees. **Settings → General →
Auto grouping** replaces the three-way *Sidebar grouping* choice; off,
unpinned tabs sit in one flat list under the pinned groups. Scratch is now
**Ungrouped**.
- **The New Tab menu names three shells, not every one the machine has.** A
stock macOS box reports nine, so the `+` menu opened on a column of `csh`,
`tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for.
The Local section now lists the default shell, always first, and then only
shells that have actually been opened, by frecency — three rows at most, the
way the SSH section already caps its hosts. The rest sit behind an "Other
Shells…" row that opens the command palette filtered to them: every shell is
now a palette command, "Shell: {name}", and running one does what its menu
row does, splitting instead when ⌥ (Alt) is held. The row is left out when
the menu already names the whole list.
- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so
`config.json` can be synced between machines for its colours and keys
without carrying a list of servers. `ssh_profiles` and
`ssh_profile_frecency` move across by themselves the first time a new build
reads an older `config.json`: `servers.json` is written first (mode `0600`),
and only once it has landed are the two keys taken out of `config.json` —
nothing else in that file is touched, including keys this build does not
know. When both files hold hosts, `servers.json` wins and the stale copy in
`config.json` is dropped at its next save. A `servers.json` that cannot be
parsed is kept aside as `servers.json.corrupt` and saving is refused until it
is repaired, the same rule `config.json` has. Hand edits to `servers.json`
hot-reload. Passwords and passphrases stay in the OS keychain.
- **`tty7 exec` runs a command in a pane that already exists and hands back its
result** (#839). `run` makes a new pane and `send` does not wait, so every
script that wanted a command's exit code from a shell it already had ended up
as `send`, a sleep, a `capture` and a guess, or an `echo $? > /tmp/rc` side
channel. `tty7 exec %3 -- cargo test` types the line at the pane's prompt,
follows the shell integration's marks to the command's end, prints what it
printed — as text, the way `capture --plain` reads it, or escapes intact with
`--raw` — and exits with its exit code. `--timeout` gives up with 124 and
leaves the command running; a pane with no prompt marks, or one that is not
at a prompt, is refused before anything is typed instead of waited on.
- **`tty7 send` takes its text from stdin or a file, and can paste it**
(#838). A token passed as `send %1 "$TOKEN"` sits in the CLI's command line,
where any local user reads it in `ps`, and in the caller's shell history;
`--stdin` and `--from-file` send the bytes exactly as read and never echo
them in `--json`. `--paste` sends the text the way a paste into the window
does — in bracketed paste when the pane has switched it on, so a multi-line
text arrives as text instead of running line by line, with any ESC stripped
so the text cannot close the paste itself. A pane without the mode gets the
same unframed paste the GUI would send it, and `--json` says which one went.
The server now reports each pane's bracketed-paste mode in `tty7 procs`.
- **The source control panel's changed files can be filtered and shown as a
tree** (#473). A filter field sits above the list: every word typed has to
appear somewhere in a file's path, in any order and any case, and the group
headers' counts and their stage/unstage/discard-all buttons follow what the
filter leaves on screen. The tile at its end switches the list to a
directory tree — directories before files, a chain of directories that hold
nothing but the next one compacted into a single row (`crates/core/src`),
each directory row foldable and counting the files beneath it. A filter
opens every folded directory while it is set. The choice between list and
tree is remembered in the config. Both are built from the status the host
already sends, so a remote repository gets them unchanged. Searching the
diff text itself is not part of this.
- **The cursor at the shell prompt can have its own shape** (#958). A new
`prompt_cursor_style` setting (Settings → Appearance → Cursor) takes
`follow`, `block`, `bar` or `underline`. `follow`, the default, keeps
`cursor_style` everywhere, exactly as before. Any other value is used at the
prompt, drawn by tty7's inline editor or by the shell's own line editor, and
leaves `cursor_style` to the programs the shell runs — so `bar` with
`cursor_style: "block"` gives kitty and ghostty's bar-at-the-prompt, block in
a TUI that never sets a shape itself, such as Claude Code. A shell prompt in
vi mode keeps its own insert/normal shapes.
- **Agent status catches interrupts, stale turns and permission prompts**
(#1003). Claude Code's `Stop` hook skips user interrupts, so after Esc or
Ctrl+C a pane stayed on working until the next prompt. Now an Esc or Ctrl+C
on a pane whose agent is mid-turn, with no hook event in the second after
it, ends the turn; and a turn that has been working for 30 minutes without
a hook event goes idle. These guessed states raise no notification or
unread badge, and the agent's next real event overrides them. Claude Code
and Codex now report a permission prompt or a question the moment it opens
rather than seconds later (Codex never did), and Codex leaves waiting once
the approved tool has run. The "finished" notification waits 1.5 seconds
and is dropped if the next turn starts first. Existing hook installs are
refreshed at launch.
- **Settings open in a window of their own, rebuilt.** Settings used to cover
the workspace, hiding the terminal a setting was being tried on. It is a
separate window now; opening it again raises it, and closing it with
unsaved forms or a theme draft still asks first. The page is rebuilt with a
searchable sidebar, per-page modified counts and a *Modified only* switch,
and an inline Reset on each changed row. Theme menus have a live preview,
search and swatches, and font menus draw each family in itself. Recording a
shortcut that another action already has asks Replace or Cancel instead of
taking it over silently. SSH hosts are one column of recent hosts, with
*Show all* by source and a search, and open in place. Integrations gets an
agent search and a menu per agent (Reinstall, Reveal hook file, Uninstall).
General gains startup and restore; updates and the server move to About.
(#973)
- **The window is redesigned.** The side rails share the window's fill,
divided by a hairline; the current tab is a neutral selected step with a
semibold title instead of a blue wash, and tab titles are spelled out in
full wherever they fit. The right panel's tabs are words — Info, Files,
Changes, GitHub. The workspace switcher, Search Everywhere, the Changes tab,
the diff overlay, dialogs, menus, the SFTP browser and the home page follow
the same design: neutral selection instead of accent washes (the
switcher's hard-coded blue row is gone), an inverted neutral primary
button, lighter scrims and headings no longer in capitals. The default
Light theme takes a new ink and accent. (#973, #993)
- **Search sits in the middle of the title bar** (#990). The centred path
repeated what the sidebar already says; it is now a field-shaped
**Search…** button, with its shortcut, that opens Search Everywhere —
centred over the terminal column on Windows and Linux. The trailing `…`
menu, which held only Search Everywhere and Settings, is gone: both keep
their shortcuts and the macOS menu bar entries.
- **Every confirmation is tty7's own dialog, and failures say what failed**
(#985). Closing a busy tab fell through to a system alert (NSAlert on
macOS, TaskDialog on Windows) while every other question was one of tty7's
cards; all confirmations are that card now, on every platform, with Return
and Escape keeping their meaning and a destructive answer painted red.
About twenty failure notices were one plain line such as `push: fatal: …`;
they are error toasts now, titled with what failed ("git push failed") and
the reason beneath. The delete confirmation says the file is removed for
good rather than moved to the Trash.
- **The code editor's header, menus and find bar match the rest of the
window** (#1005, #992). The file strip keeps only the files most recently
brought forward (three docked, seven filled, fewer with side panels open) in
a stable order; a `+N` button lists every open file with a filter, keyboard
navigation, *Close saved* and *Close others*. A Dock / Fill switch replaces
the header's right-click menu, and a filled editor leads with a pill naming
the terminal it covers that docks it back. The text's right-click menu,
which was the OS's own menu, is drawn like tty7's other menus and offers
Attach to Agent (the selected lines ride along as `#L3-9`), Find, Go to Line
and the file's own items; file tabs get Close, Close Others and Close to the
Right. The find bar is one filled pill with the match count and a clear
current-match colour, and ⌘⌥F / ⌘R (Ctrl+H elsewhere) opens it with the
replace row.
### Removed
- **The Window & Tabs settings page.** Its three tab settings — New tab
position, Tab bar position and Auto grouping — are a **Tabs** group on
**Settings → General** now, below Startup & restore.
- **The *Open diff preview from sidebar counts* setting**
(`sidebar_diff_preview`). A tab's `+N −M` in the sidebar, and the Info
panel's `changes` row, always open the diff overlay; the stalls on large
working trees that the switch was a way around were fixed alongside it. The
key is ignored if a `config.json` still has it.
- **The *By repo or folder* grouping mode, "Group Automatically", and groups
stored by name.** A folder you want grouped is pinned instead, and dragging a
tab below the divider is the way back to automatic grouping. Hand-made groups
and folds from earlier versions are not carried over. The control dialect
moves to v11, so each remote host needs one Update Server, which ends the
sessions on it.
- **Editing a saved host's authentication, proxy, port forwarding and
advanced options in Settings.** The rebuilt SSH page edits Alias,
HostName, User, Port, IdentityFile and ProxyJump; values already saved for
the rest are kept and still used. (#973)
### Fixed
- **Quick launch found only the agents in the GUI's own bare `PATH`.** The app
starts with the launchd environment (`/usr/bin:/bin:/usr/sbin:/sbin`) and
fills it in by running the login shell, but only as a *login* shell — which
reads `.zprofile` / `.bash_profile` and never `.zshrc` / `.bashrc`, where
most people export their `PATH`. Anything installed under
`$HOME/.../env/node/bin`, `~/.local/bin` or an `rc`-only directory was
invisible: `Agent:` rows for it disappeared from Search Everywhere and the
`NEW Agent Tab` menu, and a matching `agent_launch` entry did not help,
because the override's own program is looked up on the same `PATH`. The probe
now runs interactively (`-i -l`, fish unchanged — it reads its config in
every mode), reads the PATH between markers so an `rc` that prints cannot be
mistaken for it, and gives up after 5 seconds so a stuck `rc` cannot keep
the app from opening.
- **Chinese UI copy reads like Chinese** (#980). Half-width `?` and `,` in the
hard-reset confirmation, the passphrase, Finder and server naming used two
words each, and three quote/dash styles were mixed; these are now one each,
and the most literal translations (file errors, update and version-mismatch
dialogs, SSH prompts) are rewritten. The fork error that pointed to
"Settings → Agents" — a page that is called Integrations — now names it
correctly in every language and in the `tty7` CLI.
- **Return runs the top row after a search that found nothing.** Backspacing
from a query with no results to one with some — or opening the search already
filtered, as the New Tab menu's *Other Shells…* row does — left no row
selected, so Return did nothing until an arrow key was pressed.
- **A typed `ssh -p 2222 me@box` is no longer offered as an address.** The
address parser read everything before the `@` as the user name and offered
to connect as `ssh -p 2222 me`; a line with spaces is now always taken as an
`ssh` command line.
- **The character under the cursor no longer disappears in vim and Neovim**
(#966). An input-method composition with nothing visible in it — Windows
IMEs can leave one behind — was still painted at the cursor, as a cell of
the theme's background with an underline under it, covering both the
character and the block cursor on every cell the cursor moved to. A
composition that has nothing to draw is no longer painted, so the cell keeps
its character and the block cursor draws it in reverse video as usual.
- **Nerd Font icons from a fallback font come out at the text's size** (#866).
With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary
that lacks the icons, an icon followed by a space on the same background —
every icon in a coloured Powerline or p10k segment — was held to one cell
and shrunk to about two thirds of the text's height. Icons with a plain
space after them were already drawn full size, so one prompt had icons in
two sizes. A lone Private Use Area glyph supplied by a fallback face is now
fitted, aspect ratio kept, to its cells and one em of height, taking the
blank after it when that blank paints no background or the icon's own and
when doing so makes it bigger, and centred in the row; a face whose icons
ink less than a cell grows (at most 2×). The Powerline separators, CJK,
emoji, other text and the primary font's own icons are drawn as before.
Not on Linux, where the text system reports a glyph's advance box rather
than its ink.
- **A host with `Compression yes` prints again** (reported by @RedwindA in
#997). It negotiated `zlib@openssh.com`, and the SSH library tty7 used cut
every packet that inflated past twice its compressed size, so the zlib
stream desynced right after authentication: the session showed as
connected and never printed a byte. The library is now pinned to a build
with the upstream fixes. Re-importing an ssh config now also updates the
algorithms of hosts that already exist.
- **The OpenCode plugin loads on OpenCode 2.x** (reported by @ArnoChenFx in
#999). 2.x only loads a plugin's default export shaped `{ id, setup }` and
rejected tty7's with a load error. The generated plugin now serves 1.x and
2.x, follows 2.x's event API, and starts its helper without `sh`, which
Windows does not have.
- **The code editor no longer loses edits** (#984). A save truncated the file
in place, so a crash or a full disk mid-save destroyed it; local saves now
go through a temporary file renamed over the target, falling back to an
in-place write only where a rename would change something visible. The
same file open in two tabs is one buffer, not two diverging copies.
Closing a tab, its last pane or the window, or quitting, asks about
unsaved files (Save / Cancel / Discard) instead of dropping them. Renaming
or deleting a file in the Files tree follows the open buffer, so a save no
longer recreates the old path, and a save asks before overwriting a change
made elsewhere. Undoing back to the saved text clears the unsaved mark, and
a reload keeps undo.
- **Typing Chinese through an input method no longer crashes the code
editor** (#993). When a re-parse ran past its time budget, stale
highlighting could put a style boundary inside the committed character and
panic the app.
- **Selecting text in rendered Markdown no longer blanks it out** (#991). The
selection was painted over the glyphs, so a multi-line selection in, say, a
pull request's description became a solid grey block.
- **The Processes and Ports sections see the pane's shell on a busy Mac**
(reported by @zcyc in #731). The macOS process list read the pid count as a
byte count, so on a machine with about 700 processes only the newest 60 or
so were seen; a pane whose shell started earlier showed no processes and no
ports, with nothing flagged.
- **A Files tree on a WSL distro stays in step with it** (reported by
@zt449569708 in #942). Copying a downloaded file onto the distro's share
no longer brings a `name:Zone.Identifier` file with it; a file removed from
the shell inside the distro leaves the tree (the share accepts a change
watch and never reports a change, so it is now polled every 2 seconds); and
right-clicking a row selects it.
- **The quit confirmation on Linux is no longer cut off mid-sentence**
(reported by @XuJinNet in #920). Linux has no native dialog, and the
fallback set the message and detail as single unbreakable lines in a
fixed-width box. Its text wraps now.
- **Fork no longer picks up the previous agent's session id** (by
@CodeHourra in #957). When the foreground agent in a pane changed, or
another agent's hook wrote to the same terminal, the old session id stayed
with the new agent.
- **Windows no longer freeze for a round trip when a remote watch closes.**
Closing a watch on a remote workspace waited for the server's answer, often
on the UI thread, so every window stalled for a round trip — up to five
seconds on a quiet link. The request is now sent without waiting.
- **A burst of pane output no longer holds up the UI.** The reader parsed each
batch, and a reconnect's whole replay of up to 8 MB, in one hold of the
grid lock that every UI handler touching the grid waits on. It now lets go
every 64 KB.
- **A command named in the search leads over a session that mentions it**
(#985). Typing `worktree` and Return resumed a past agent session instead of
opening New Worktree Tab: the fuzzy matcher spent the first letter on its
leftmost occurrence and scored a title containing the whole word as a poor
match, and session titles often open with a command's name. The matcher now
keeps the best alignment, and on the All tab sessions yield to an equally
good command.
- **Menu items bound to Return or Tab show the right shortcut on macOS.** A
menu item bound to ⌘↵, such as Toggle Fullscreen, read as ⌘E. (#973)
## [26.9.3] - 2026-09-23
### Added
- **A tty7 server can be updated from the command palette, here or on the remote
host** (#931). Between dialect bumps a remote host kept whatever binary
already sat at `tty7-server-c{control}p{protocol}` — `replace` re-uploaded
only on a dialect mismatch — so a daemon-side fix never reached it, and
"Update Server" restarted the same old file. "Update tty7 server on
"{machine}"…" is listed when the workspace is on an SSH or WSL host whose
server tty7 installs (not a `--stdio` program) and forces the upload: the new
binary lands under a temporary name, must answer our dialect, and only then is
renamed over the published path and the daemon cycled. An upload that fails
that probe leaves the old file and the running server alone. The confirmation
says every session on that machine ends. "Update tty7 server on this
computer…" restarts the local daemon onto the build the app already ships,
through the same handoff-or-restart choice, and says "already running this
build" instead when the last probe reported our protocol and build. The build
string is the crate version, so two nightlies of one version look identical.
The remote update goes through the local daemon's router, so a local daemon
older than this asks for itself to be updated first rather than dropping the
request silently.
- **The SSH host editor takes a password, a key file and its passphrase**
(#875). A host could be described in full in Settings and still not be
connectable from there: there was no password box on the form, so storing one
meant connecting and ticking "remember", and correcting one meant failing
first. The key file sat two disclosure triangles deep under Advanced as a
textarea of paths. An Authentication block now carries a masked password with
a reveal toggle, seeded from the keychain so a stored one can be read back,
corrected or cleared; identity files with a Browse… button that writes the
path back as `~/.ssh/...`; and a key passphrase stored against the key's
contents, the account the connect-time prompt already uses. Which boxes appear
follows the method, on the same split the handshake uses — a password for Auto
and Password, passphrases for Auto and Key, nothing for Agent, GSSAPI or 2FA —
since a box outside it would store a secret nobody is ever offered. Nothing
secret reaches the config file, which is why Save could not see a typed
password; the dirty check folds the secrets in now. A password is filed under
the endpoint, so saving a new address moves it and leaves nothing behind
unless another host still dials the old one. Test dials with what is on
screen rather than only what is stored. Labels now sit against their fields
rather than across the page from them.
- **CodeBuddy CLI support, and Cursor CLI reports its session and status**
(#936, requested by @lgc653 in #892 and by @rrpolanco in #741). CodeBuddy
gets detection through all three of its binaries (`codebuddy`,
`codebuddy-code`, `cbc` — the last shared with the COIN-OR solver), hooks in
`~/.codebuddy/settings.json` or `$CODEBUDDY_CONFIG_DIR`, status, resume and
fork. It emits `SessionStart` with `source: "compact"` in the middle of a
turn, which is filtered out so the pane does not fall back to idle. Resume and
fork drop the stale session and worktree flags from the replayed launch, and
`--no-session-persistence` disables both. `cursor-agent` gets hooks in
`~/.cursor/hooks.json`. Cursor ignores a hooks file without `"version": 1`,
so tty7 writes it when it creates the file or finds the key missing, and never
overwrites a version the user set; a versionless file holding our hooks
reports Outdated so a refresh repairs it. Its payloads call the session
`conversation_id` and carry `workspace_roots` instead of `cwd`, and both are
read. A turn opens on `beforeSubmitPrompt` and closes on `stop`, with
`postToolUse` counted as activity; mapping `postToolUse` to the turn's start
instead froze the activity counter, shortened turn timing and hid turns that
ran no tools. `cursor-agent -p` does
not send `beforeSubmitPrompt` or `stop`, so a print-mode run records its
session id but shows no status dot.
- **The Info panel says how far away a remote pane's shell is** (#862). The
Session table named the machine but never the distance to it, so a remote
workspace gone slow looked exactly like one that had not. A `latency` row now
shows the round trip of a `Ping` on tty7's control link. Only `Ping` is timed:
every other request does work on the far side, and timing a large `ReadFile`
would read as a network gone seconds slow. The row takes a ping of its own
every two seconds while the panel is open, rather than the keepalive's, which
only fires on an idle link. It is held per host, so moving between two panes
of one machine keeps the number; a dropped link keeps its last measurement,
since that is when someone is reading the row; and before the first ping
returns it reads `—` rather than `0 ms`. The measurement is client-side, so
it works against every server that can connect.
- **A file in a remote workspace's tree can be downloaded from its context
menu** (#937, requested by @jerryokk in #723). The tree took uploads by drag
and drop but had no way to bring a file back. Download sits where a local tree
has Reveal in Finder and saves straight into `~/Downloads`, as the SSH pane's
SFTP panel already does, numbering a second copy `name (2).ext` rather than
overwriting the first. A file over the ~63 MB single-frame cap uploads share
is refused before anything crosses the wire, with a pointer to scp or rsync.
Files only; a folder would need a recursive walk or an archive.
- **Windows portable mode keeps its data beside the executable** (#941,
requested by @netcatty in #852). If the folder holding `tty7-app.exe`
contains both the `.tty7-portable` marker and a `data\` folder, `data\` is
the config directory — settings, themes, sessions, scrollback, history, and
the daemon's socket and lock — and the `tty7.exe` beside it resolves the same
directory from outside tty7. The marker alone is not enough because every
portable ZIP has shipped it since #330 as the updater's cue: switching on it
would land every existing ZIP user on an empty directory with their settings
still in `%APPDATA%\tty7`. Creating `data\` is the opt-in. `--config-dir` and
`$TTY7_CONFIG_DIR` still win over it, a portable copy is never handed the
installed tty7's legacy tree, and an in-place update leaves `data\` alone.
- **Next Tab and Previous Tab step to the neighbouring tab without the
switcher** (#934, requested by @rrpolanco in #867). `NextTab` (Ctrl+Tab) goes
through the recent-tab switcher, whose quick tap commits to the last-used tab,
so repeated taps bounced between two tabs and never reached a third. New
`SelectNextTab` / `SelectPrevTab` step along the order the strip or grouped
sidebar shows, wrapping around, with no popup. They take `⌘⇧]` / `⌘⇧[` on
macOS and Ctrl+PgDn / Ctrl+PgUp elsewhere, where `Ctrl+Shift+]` / `[` already
move between panes — which means those two chords no longer reach the PTY, and
vim's `:tabnext` binding with them; unbinding the two actions gives them back.
The tmux preset's `prefix n` / `prefix p` move to the new actions, since the
switcher never committed there with no modifier held. `NextTab` / `PrevTab`
keep their config names and are now labelled "Recent Tab Switcher"; the
palette's Next/Previous Tab entries, which already cycled, now follow the
shown order rather than the raw index.
- **Stroke thickening on macOS can be turned off** (#939, requested by
@xiaozhaodong in #720). `font_thicken` (default on, so nothing changes
unasked) under Appearance → Terminal text → Thicken strokes stops CoreGraphics
smoothing from dilating glyph strokes, so text renders at the face's own
weight whatever its colour, like Ghostty's `font-thicken` and iTerm2's thin
strokes. It sets `AppleFontSmoothing = 0` in the process's volatile argument
domain before gpui first reads it — nothing is written to disk and no other
app is touched, and with the setting on a hand-written
`defaults write ... AppleFontSmoothing` keeps working. gpui caches the value
once, so a change applies after a restart.
- **The code panel's Markdown preview and word wrap are actions**
(#935, requested by @swaynehales in #754). The two status-bar toggles were
mouse-only. `ToggleDocumentPreview` and `ToggleDocumentWrap` are in the
palette and bindable, with no default key, and the buttons call the same
code. The last state is remembered and applied to the next file opened;
preview only applies to Markdown, and a file opened at a line target, such as
a clicked `README.md:42`, opens as source so the cursor can be seen.
- **An orphaned pane can be put back on screen with `tab new --pane %N`**
(#831, reported by @xAlisher in #716). The only verbs for an orphan were the
two that kill it. The tab is built from the live pane registry rather than
the tree, because closing a tab drops the pane from the tree at the moment
it orphans it; the ssh spec, agent and shell are therefore not recovered. A
pane the server is not running, or one a tab already holds, is refused. With
no workspace named, the pane goes back to its recorded owner. `pane ls --all`
now names this way back before the two ways to kill.
- **`tty7 capture --tail N` keeps the last N lines of the answer** (#855,
reported by @rrpolanco in #841). It composes with `--scrollback` and
`--plain`. The daemon still replays the whole ring, so it saves the pipe, not
the wire.
- **Qoder CLI support** (by @WhiteDG in #873).
- **A Pi pane reports that it is waiting for you** (by @netcatty in #878). The
bridge now maps Pi's `ui_prompt_start` to a permission request or question
and `ui_prompt_end` back to working, each guarded so an Oh My Pi fork without
the hook loses only that event.
- **Crush support** (by @akhenakh in #881). Crush only offers a pre hook.
### Changed
- **Settings is organised around what is being configured, and search results
can be edited** (#915). The groups are now General, Appearance, Terminal,
Keyboard & Mouse, Window & Tabs, SSH, Integrations and About; About keeps app
information and update status, and update preferences, proxy and tty7 server
controls move to General. Search matches configuration keys and localized
aliases, edits an ordinary setting in place, filters to modified settings,
resets a setting on its own, and is navigable from the keyboard; shortcuts and
larger forms keep their own editors. Ordinary settings save as they change and
show a failed write with a retry. Theme drafts and SSH forms get an explicit
Save and Cancel, and leaving unsaved work asks Save, Discard or Keep Editing.
Terminology is aligned across English, Chinese and Japanese.
- **The native chrome has one colour and icon system, and agent marks carry
their brand colour** (#914, #916). Primary actions, navigation selection,
keyboard selection and status colours are separate roles, and UI status no
longer borrows the terminal's ANSI palette. Toolbar glyphs share one set of
sizes, and file names, paths, labels and selected rows are told apart. The
default Dark palette is charcoal with a restrained blue accent, and the
default left sidebar is 260px wide; a saved width is kept. The first pass
turned every agent in the rail into the same grey disc, which took away the
quickest way to tell one agent from another down a column. The mark itself is
now painted in its brand colour with no disc, at about a third of the coloured
area, so it no longer outweighs the status dot beside it; a colour that cannot
be seen on the surface under it — Codex's and Grok's black on a dark theme —
is lifted, keeping its hue. Resting toolbar tiles drop to the caption ink, so
`+` and the panel toggle are no longer the darkest marks in the sidebar.
- **A keybinding in config adds a chord beside the default instead of
replacing it** (#887, reported by @rrpolanco in #868). `"NextTab":
"cmd-shift-]"` silently removed Ctrl+Tab: the keymap held one chord per action
and a configured one overwrote the slot. A string now adds; `""` still
unbinds; a list such as `["a", "b"]` is the exact set, replacing the default,
and `[]` unbinds. Every existing file still loads, and a save writes back the
shape it read. Configured chords are installed after every shipped one,
because gpui settles two bindings on one chord in favour of the one added
last, which let a user's `"NewTab": "ctrl-tab"` lose to `NextTab`. Settings →
Keybindings shows every chord an action has; recording a shortcut sets it and
writes the list form, and a chord taken from another action removes only that
chord from it rather than emptying the action.
- **Workspaces are numbered in a stable order** (#938, requested by
@Morphone0429 in #760). `SelectWorkspace1`–`9` and the Window menu numbered
workspaces most recent first, so switching to one moved it to slot 1 and a
number never meant the same workspace twice. The number now follows the order
this client first had each workspace. A synced remote workspace this client
has never opened takes no number — otherwise connecting to a busy devbox would
take the next free slots — and on first open it takes the next free one
without shifting the others. The Window menu lists the first nine in that
order, open and closed together; a tenth and later, and never-opened synced
ones, are reached from the switcher. The switcher stays most recent first,
because the Ctrl+Tab hold-and-release depends on it, and shows each row's
number. Deleting a workspace moves the ones after it up a number.
- **Panel text stays readable across themes and sizes** (#933). Compact
section headings take one size on both sidebars, Session labels take the body
size, and muted text is checked against the surface it actually sits on —
content, sidebar or popover. A Git file name shrinks with an ellipsis and
shows its full path on hover, the branch name keeps room in a narrow panel,
and rows and settings navigation grow with the interface font. Static process
IDs and separators are quieter, and settings description rows no longer look
clickable. Floating surfaces share one radius and one selection treatment.
- **An SSH connection proves its remote server once, not once per pane**
(#824, reported by @qiudaomao in #695). Every new pane re-ran the full
installer probe on a connection that was already up and serving — `uname`,
a control probe that spawns the server binary, a walk of `/proc` for the
running build, and two SFTP reads, all serial and all before the pane's own
channel opened. The answer is now kept on the connection, so a reconnect
starts over by construction. It is dropped before a replace or restart, and
when a routed link closes without the remote sending a byte. A failed probe
is not remembered, and a build mismatch the probe found is raised again for
every pane rather than only the first. Round trips per later pane go from five
to none; the wall-clock gain on a real link has not been timed.
- **The Windows binaries no longer need the Visual C++ redistributable** (#907,
reported by @ra9fael in #902). A default MSVC build links `VCRUNTIME140.dll`,
which is not part of Windows, so on a machine that had never installed the
redistributable tty7 failed before `main`. Every machine that builds tty7 has
it, so nobody positioned to notice could. The CRT is now linked statically,
set in the repository's cargo config so CI builds under the same flag a
release does. A check that reads the PE import tables directly, without
`dumpbin`, runs in CI and over the packaged ZIP and installer payload.
- **The control dialect is v10, so remote hosts pick up the daemon fixes**
(#930). No message changed. A remote server's filename carries the dialect
number and tty7 keeps whatever binary already sits at it, so without a new
number the daemon-side fixes in this release would never reach a remote host.
Each remote host shows the Update Server prompt once, and accepting it ends
every session there. Locally, the first launch after the update shows the
daemon mismatch prompt once; where the daemon supports handoff, restarting
keeps the shells.
- **The SFTP Files panel opens where the shell is now** (#932, requested by
@loinky in #826). It started at the shell's directory only the first time;
every later open went back to wherever you last browsed. A fresh open now
prefers the shell's current directory, then the last folder browsed, then the
login directory. Switching panes with the panel still open returns to the
folder browsed there, as before.
- **The prompt editor starts input on its own row when the prompt leaves too
little room** (#940, requested by @imeilige in #767). A 119-column prompt in
a 143-column pane left 24 columns for the first row of input. When fewer than
`max(20, cols / 3)` columns remain, input starts at column 0 of the row below
— but only if that at least doubles the room, so a short prompt in a narrow
pane stays put. Clicks, Up/Down, the completion menu and the IME candidate
window all follow the moved start. It applies only to the built-in prompt
editor; with `prompt_editor: false` the shell lays out its own line.
- **The Close Pane / Tab menu item is labelled Close** (#910). The palette and
the Keybindings page keep the full name, where it stands alone.
### Fixed
- **The ssh password prompt stops coming back after a reconnect** (#827, #924,
reported by @sparklive in #820). Three defects stacked on one dropped link.
Evicting a dead connection removed its slot from the cache, and that slot's
mutex was the only thing serialising dials to a host: every pane on the link
evicted the slot the one before it had just made, ran its own handshake and
raised its own sheet, so answering one only uncovered the next. The slot now
stays and only what it points at is dropped — one handshake, one prompt, and
the other panes reuse the connection it made. Closing the password sheet
failed only the `password` method, and on any host that also offers
`keyboard-interactive` that is the same question asked again; closing it now
ends the attempt. A declined remote-workspace reconnect went into backoff and
dialled again within a second, forever; it now suspends the machine and the
strip offers **Retry**. A password the server rejects still falls through to
the next method, and closing a key-passphrase sheet still just skips that key.
Separately, a routed prompt had no idea whether anyone was still waiting on
it: one left over from an attempt that had timed out stayed parked or on
screen, and typing into it sent the secret into a dropped channel. Such a
prompt is now retired, an on-screen one taken down, and the sheet waits no
longer than the daemon's handshake does (120s rather than 180s).
- **A pane that comes back from a workspace switch keeps its full-screen
program** (#832, reported by @Infiniverse in #711). The replay arrived whole;
the client threw it away a few frames later. An attach ends with the pane's
shell state, and a client told the shell is at a prompt scrubs a leftover
alternate screen with `?1049l` — right for a `vim` whose `ssh` dropped, wrong
for the alternate screen the replay has just rebuilt, which it swapped away to
reveal the banner and the launch command underneath. The agent carried on
drawing differential updates into a grid that no longer held what they were
differences from. That prompt state is only as fresh as the last OSC 133
mark, and a program that sends none of its own leaves it set; the live path
already refused such a mark, and the replay now asks the pty the same
question and reports a prompt only when nothing but the shell owns it.
- **A pane restored after a server stop no longer types mouse reports into its
new shell** (#853, reported by @hhdebb in #850). A restored pane replays the
killed program's raw bytes, so its `?1002h` was executed again against a shell
that never asked for it, and every pointer move landed on the prompt as an
SGR report. The preamble appended after the restored output now also turns
off mouse reporting and its encodings, focus reporting, bracketed paste,
application cursor keys and the kitty keyboard flags. It clears them
unconditionally rather than folding the snapshot for what was left on: the
program that set them is dead, the shell is brand new, and turning off a mode
that is already off is a no-op everywhere, where a fold that misread one
sequence would bring the bug back silently.
- **A full-screen program keeps its terminal modes across a reconnect** (#828,
reported by @shihuaidexianyu in #774). A reattach replays the pane's output
ring, which drops from the front at 8 MiB, so a program that set its modes
once at startup — `btop` — came back on the primary screen with no mouse
reporting, and the wheel scrolled a screen that should not scroll. The daemon
now folds the bytes it hands the ring into a small mode tracker and re-sends,
ahead of the ring, the modes the ring itself can no longer speak for:
alternate screen, mouse reporting and encoding, alternate scroll, DECCKM,
focus reporting, bracketed paste. Only those, because re-sending a `?1049h`
the ring still carries made the ring's own copy a no-op and swept the shell
scrollback ahead of it into the alternate buffer. Cursor visibility and
autowrap are deliberately not restored, and a pane adopted across a daemon
restart starts with an empty fold.
- **`:wq` on a remote pane lands where it was typed** (#828, reported by
@shihuaidexianyu in #774). The repair that puts back a cursor ConPTY parked
during a repaint was decided at compile time, so a Windows client applied it
to panes on a remote `tty7-server` and to native-SSH panes, neither of which
has a conhost in the way — and `wq` went two rows above the `:`. It is now
decided per pane from the route it was opened on, with the remote context
answering what the route cannot.
- **Panes opened after tty7 restarts or updates keep Local Network access on
macOS** (#917, reported by @jgrund in #909). macOS attributes Local Network,
camera, microphone and similar decisions to a process's responsible process,
inherited at spawn — and the pane daemon was spawned by the GUI and outlives
it. After an in-place update, a crash or a force-quit, new shells answered to
a GUI that no longer existed, so Homebrew `node` or `python` got
`EHOSTUNREACH` on the LAN while `/usr/bin` tools, being exempt, kept working.
The daemon now re-execs itself in place at startup with the responsibility
disclaimed: same pid, same descriptors, but its own responsible process. It
always re-execs once rather than asking who it answers to, since a process
whose responsible parent has exited reports itself — exactly the orphaned
state this has to repair. Because it also runs on the far side of a handoff,
**Restart Server** repairs a daemon started by an older build without losing
panes; shells already open keep the attribution they were born with. The
call is private SPI resolved at run time, and startup carries on as before if
it is missing.
- **F1–F12 reach the shell** (#835, reported by @steelywing in #834). No
function key was ever encoded, on any platform. They now send
`xterm-256color`'s own table, modified forms included, under both the legacy
and kitty encoders. Two swallows sat in front of the encoder as well: the
prompt editor dropped a named key it had no binding for, and F3/Shift+F3 were
eaten by Find Next/Previous with no find bar open — they now hand the key
back. Under the kitty protocol F3 is `CSI 13~`, since the letter form
collides with a cursor position report, and F13–F24 are sent in the
protocol's private-use range; the legacy path still sends nothing above F12,
because terminfo already spends `kf13` onwards on the modified F1–F8. F11
stays fullscreen, now as a pinned decision.
- **A tab stops wearing the title of a program that has exited** (#908,
reported by @rrpolanco in #889). An OSC 0/2 title was last-writer-wins
forever, in the daemon's record and in the window, so after Claude Code, vim
or lazygit exited the tab kept its last title over the pane's own directory.
A title written between a command's `133;C` and `133;D` now belongs to that
command and is retired by its `D`; a title set at a prompt, or one a shell
writes for itself — which comes after the `D` — stands, and a pane with no
shell integration behaves exactly as before. A window reattaching mid-command
whose `C` has rolled out of the replay ring takes the replayed prompt state
as proof a command owns the pane, so it retires those titles too.
- **Unread badges only mark turns the reader has not seen** (#888, #890,
reported by @rrpolanco in #870). Switching workspaces, reopening from the
tray and restarting the app all build new views over live panes, and a new
view saw an agent that was already `Done` as a turn finishing just now — so
every agent tab outside the active one got its `1` back. The daemon now
counts finished turns, and views leave a read mark per pane for the life of
the app: a rebuilt view whose mark matches takes the badge back as the reader
left it, while a turn that finished while it was away still badges. After a
restart, with no marks, a reattach adopts the replayed status as its baseline
instead of an edge; a relink keeps the view it already had, so a turn that
finished while the link was down still badges. Against a remote server older
than the turn count, a later turn finishing while away looks like the one
already seen until that server is updated.
- **A symlinked config file stays a symlink** (#918). Every save wrote a temp
file and renamed it over the path, which replaces a symlink with a plain
file — so a `config.json` linked into a dotfiles repo quietly stopped syncing
the first time the sidebar was dragged. The link chain is resolved first,
relative and dangling targets included, and the rename lands on the real
file; a loop falls back to the old behaviour. It covers every file written
that way, `views.json`, `window.json` and the machine stores among them.
- **One unreadable keybinding no longer resets the whole config, and a shortcut
can be left unbound** (#906, reported by @leoatchina in #901). A single
`keybindings` value serde could not read failed the entire `config.json`,
which was quarantined, the app started on defaults, and the next settings
write saved those defaults over everything the user had written. That line is
now logged and skipped on its own. And nothing in the app could unbind an
action: ⌫ on a Keybindings row reset it to the shipped chord, a no-op on a row
nobody had touched, so Alt+1…9 could not be handed back to vim. ⌫ on an
unrecorded row now writes the empty list, the row shows `—` and gains the
**Reset** button, and the capture hint says so.
- **A tty7 retired to the tray comes back from the Dock** (by @fish2lab in
#880). With its last window closed the process stays up with its Dock icon,
but a click on the icon went to a reopen handler tty7 never registered. The
reopen now activates a window if one exists, and otherwise restores the
workspace that retired, the same way the tray's **Show tty7** does. Why a
window can vanish across display sleep is not explained by this.
- **Reaching for ⌘ mid-flick no longer zooms the font to its minimum** (by
@wenlingang in #913). A trackpad's momentum tail carries the modifiers held
when each event is delivered, so pressing ⌘ while a scroll coasted turned the
rest of it into dozens of zoom steps, and the clamped size was saved. A
gesture now decides scroll or zoom at its first event and keeps that answer
to the end of its tail, a zoom whose modifier is released included; each new
gesture decides for itself. A wheel still decides notch by notch.
- **The prompt gets its cursor shape back when a program exits** (#922,
reported by @rrpolanco in #837). nvim and vim restore terminfo's `Se` on the
way out, which for `xterm-256color` is `\e[2 q`, an explicit steady block, so
every prompt after them stayed a block whatever `cursor_style` said. The
reader notes the cursor style at `133;C` and restores it at `133;D` if the
command left a different one, back to following the configured default when
that is what the prompt had. `D` is the first thing the precmd writes, so a
vi-mode plugin still has the last word; a shell without integration keeps the
old behaviour.
- **Narrowing a pane under a prompt leaves one clean prompt** (#929, reported by
@coolmanj in #654). With something right-aligned on the prompt line, the
reflow wrapped it onto a second row and left the cursor on the tail, and the
shell's SIGWINCH redraw — up by the rows it last drew, then clear below —
started from there, stranding the old prompt's head; a drag in and out filled
the pane with them. A column change at a prompt on the primary screen now
clears the cursor's line from its first row and puts the cursor where the
shell's redraw expects to start. Only the last line of a multi-line prompt is
handled.
- **A resize whose echo went missing is sent again** (#923, reported by
@rrpolanco in #893). The grid reflows only when the daemon echoes a size
back, but a size once asked for was never asked for again, so a lost resize
or echo left the pane painting a grid shorter than its bounds — blank bottom
rows — until a divider drag asked for a different size. Past a grace period,
a grid that does not hold the requested size gets it sent again. One proven
way for the two to drift, not established as the report's.
- **A replay never starts in the middle of an escape sequence** (#927, reported
by @404KSG in #857). The replay ring evicted at an arbitrary byte, so a cut
through a `133;C` mark came back as `33;C` on every reattach and a cut
through a CJK character as a replacement glyph; eviction now continues until
the front is outside every sequence and on a character boundary. The OSC 5522
clipboard sniffer dropped a held `ESC ]` when another escape interrupted it,
so a read boundary could turn `ESC [31m` into a literal `[31m`. Neither is
proven to be the report's duplicated lines.
- **A tab comes back to the pane focus was last in** (#854, #921, reported by
@rrpolanco in #843 and #869). The pane to return to was sampled as the tab was
switched away from, which found nothing whenever focus was off the panes —
and a switch through the switcher first restored focus to the first leaf and
then wrote that down. It is recorded as focus arrives now, carried across a
connecting slot becoming its pane. Un-zooming hands focus to the pane that was
zoomed rather than to the record, and zooming with focus off the panes zooms
the pane the tab remembers instead of its first.
- **Shift+Enter and Ctrl+J insert a newline in native Windows Codex** (by
@doitian in #895). They sent LF through ConPTY, which reports it as
Ctrl+Enter, a chord Codex ignores. A local ConPTY pane now gets explicit
Ctrl+J key events — one LF for VT readers — while remote ptys keep plain LF
and a negotiated kitty encoding still takes precedence.
- **Leaving an ssh session no longer leaves `^U` at the prompt** (by
@spragginsdesigns in #877). The typeahead record kept lines already
submitted, so draining `exit` still asked for a line wipe with nothing to
replay. Enter now discards the submitted record, and Ctrl-D on an empty line
ends input the way Ctrl-C does; with text on the line it is an edit, and text
typed during a gap still gets its wipe.
- **Holding Backspace in a WSL pane holds one steady bell flash** (#884,
reported by @seahoe in #874). bash rings on every key-repeat at an empty
line, and each flash armed its own 150 ms timer, so an older bell's timer
blanked a newer flash and the pane strobed. Only the latest bell's timer
clears it now; a single bell looks as before.
- **Dim text stays readable on light themes** (#886, reported by @AaronNick in
#858). SGR 2 was a fixed 66% fade, which on a light background dropped
Catppuccin Latte's foreground from 7.06:1 to 3.18:1 and bright black to under
3:1 on every light builtin. A dim cell on a light background is now held at
4.5:1, never darker than its undimmed ink; dark backgrounds keep the old fade
byte for byte, and the legible-palette switch turns it off. The unreadable
Claude Code text in the report was its dark theme's truecolor, which tty7
renders as sent — `/theme auto` there picks the light one.
- **`tty7 wait --until free` answers on remote and SSH panes** (#856, reported
by @rrpolanco in #840). A pane routed to a remote daemon had no local tree to
walk, and a pane running `ssh` has one that never empties, so both read busy
forever. The far shell's prompt marks now answer freeness there, and a remote
pane whose far shell has no shell integration ends the wait with `status:
unknown` and a reason instead of hanging. On a local pane a prompt mark can
only turn busy into free, so `free` now means "will take input", a nested
shell's prompt included.
- **`tty7 capture` no longer answers a live pane with nothing after a resize**
(#855, reported by @rrpolanco in #841). A resize opens an empty segment in the
replay ring, and the default form returned the newest segment — that empty
placeholder. Empty segments are skipped now. That is all it fixes: on Unix
the segment a resize opens holds the prompt's repaint, so the default form
can still return just a prompt, and anything reading a pane under the GUI
should ask for `--scrollback`. `--json` reports the replay's `bytes`, and a
replay that renders blank says so on stderr.
- **The Ports panel says when it could not look** (#830, reported by @zcyc in
#731). The process walk stopped at the 64 rows the panel draws, and it
visits the newest child last, so the server started a moment ago was the one
most likely to be cut; the walk now goes to 512 and only the drawn list is
trimmed. A missing `lsof` (it lives in `/usr/sbin`, which a hand-written
`PATH` drops), one wedged past three seconds, a Windows table that would not
answer, or a tree holding another user's process each used to read as
"nothing is listening". The panel and `tty7 procs` now say which, and say
nothing extra when the probe worked.
- **Walking into a workspace the machine already has keeps its name, and the
machine tree keeps earlier copies** (#831, reported by @xAlisher in #716). A
name typed for one workspace was sent as a rename to whichever workspace the
window settled on, so arriving at an existing one renamed it after the
arriving client; the name now carries the workspace it was typed for and is
spent only on the create it rode with. `machine.json` was replaced whole on
every write, so the write that lost a layout erased the only copy; up to three
earlier generations are now kept beside it, at least five minutes apart, and
a tree that will not load is recovered from the newest one that parses.
- **The Files panel lists a WSL pane's directory** (#925, reported by
@zt449569708 in #896). Its POSIX cwd was read as a Windows path, so the panel
said "Could not be read" and drops into it failed. It now goes through the
distro's `\\wsl$` share, and a cwd no host here can read roots nothing.
- **Native SSH tabs group by their remote folder** (#926, reported by
@rrpolanco in #891). Under repo-or-directory grouping they all landed in
Scratch; the cwd the remote shell reports now files them under its folder.
Repo grouping still keeps them in Scratch.
- **A fullscreen window on Windows and Linux drops its dead window buttons**
(by @hhdebb in #864). Minimize, maximize and close stayed on the title bar in
fullscreen, lit up under the pointer and did nothing. They go now and the room
they held comes back to the strip; the row itself stays, and entering
fullscreen says how to leave. macOS is unchanged.
- **An agent's spinner is taken off the tab title it writes** (by @hhdebb in
#865). Braille spinner frames and Claude Code's `◐◑◒◓` and `✳` are stripped
from the front of a title, only with whitespace behind them and never from a
name the user typed.
- **`font_fallbacks` applies to panes already open** (by @hhdebb in #879), and
changing `font_family` rebuilds the chain for the new family instead of
carrying the old one over, bold and italic faces included.
- **The terminal is reported as the focused element to assistive clients** (by
@hhdebb in #872). A screen reader had nothing to announce, and a dictation
tool decided its paste had failed when it had not.
- **A GitHub rate limit on the update check says when to retry** (by @ayamir in
#861), for a 429 as well as a 403.
- **Settings rows keep their shape.** A long Codex failure note on the Agents
page lost its width cap and squeezed the label to one character per line (by
@wenlingang in #898); keybinding action names in CJK wrapped every few
characters over the rows below and now stay on one line (#928, reported by
@XuJinNet in #919).
- **A docked document takes the window's right edge on macOS.** With the
detail panel closed, the panel toggle and the app menu tile stayed at the end
of the terminal column's strip, stranded in the middle of the window beside
the document's own header. They now give way to the docked column, and the
tab strip gets their width back; ⌘J and the palette still reach both.
- **The file tree sits on the right panel's rail** (#859), so its root lines up
with the search field above it and a selected row is as wide as it is under
Info and Source Control.
- **The docs explain why a shell started by hand stops reporting its
directory** (#825, reported by @hardboydu in #698), and no longer promise a
process-inspection fallback on SSH or Windows panes.
## [26.9.2] - 2026-09-10
### Added
- **A path is read out of the prose glued around it.** File detection used to
take the whitespace-delimited token under the cursor, peel a bracket off each
end and hope, which everything a build tool writes onto a path defeated:
`--file=src/main.rs`, `note:src/x.rs`, a diff's `a/`, `ls -F`'s `src@`, a tree
glyph with no space behind it. It is now a short ordered ladder of readings —
left cuts name the prefixes that actually occur and stack against each other,
right cuts trim sentence punctuation balanced-aware so `report(1).pdf`
survives, and at most eight readings are tried per token. Location parsing
grows `app.ts(10,2)` and `main.rs#L10` beside `:10:2`, and a path carrying no
line number of its own takes one from beside it, so `File "handlers.py", line
214` lands on the line rather than the top of the file. Hovering no longer
needs the modifier: a resolved link underlines at 45% as soon as the pointer
reaches it and only turns solid with a hand cursor once the modifier is down.
Right-clicking a path opens a menu about that path — open, show in the file
manager, copy path — and a file the built-in editor cannot read is handed to
the desktop instead of refused.
- **A tab can be put in a sidebar group by hand.** Groups were derived and
nothing else: the sidebar read a tab's cwd and filed it under the repo it
found, so tabs that belong together for a reason the cwd cannot see — a few
ssh sessions, three forks of one project, the two panes an investigation is
spread across — had no way to sit together. A tab now moves into a named group
from its context menu, and a stated group is never recomputed, so the probe no
longer drags a hand-placed tab home on the next frame. Dragging a tab onto a
custom group moves it there as well; a lifted tab fades every block that
cannot take it, since a repo group's membership is decided by its tabs' cwds
and "put this tab in tty7" is not a request the sidebar can honour honestly.
Custom headers carry an asterisk, renaming rewrites every tab in the group in
one pass, and "Group Automatically" gives a tab back to the probe.
- **Sidebar groups fold** (#804). A group folds shut when its header is clicked
and stays shut across launches. A live search outranks the fold — a row a
query matches shows whatever its group says. Folded rows register no
rectangle, so a pane cannot be dropped into a group that is shut, and the
header still counts every row the group has.
- **A remote pane's listening ports are detected and forwarded** (#787 for
Windows). A remote workspace's ports were never listed: the pane lives in the
peer's registry and the query went to this machine's daemon, which has never
heard of it, so the answer was an empty list — indistinguishable on screen
from a pane serving nothing. The peer answers now, gated on a feature so an
older server says "I cannot tell you" rather than "nothing is listening". With
the ports visible the forward stops being something to think about: a port
opens on a click and a new one is forwarded unasked, at the same number where
that number is free here. Ports and Forwards were two sections that never
mentioned each other; a row is now a port, and the forward is where that row
says it comes out.
- **tty7 can be the system's default terminal on macOS** (by @ayamir in #818).
LaunchServices hands `ssh:`, `x-man-page:` and script opens to tty7. The URL's
authority is read off the parsed URL rather than through Quick Connect's
`user@host:port` reader, so a port, a path and percent escapes all survive;
`x-man-page://3/printf` carries its section; and an external open that arrives
while a window is still pulling its layout is parked rather than inserted,
which is what used to turn a single tab into the whole workspace.
- **A bindable Close Window action** (by @bytehello in #778, reported by
@rrpolanco in #773). 26.9.0's tray-retire model made closing the last window
the "keep the daemon, drop the UI process weight" gesture, but that path was
reachable only from the OS red close button. `CloseWindow` is that action, with
no default key. The decision behind a window close — detach the workspace, and
on the last window retire to the tray if an icon is actually up, otherwise quit
— moves out of the close handler so the button and the action share it. In the
palette it sits beside Quit, because that pair is the point: both end the
window in front of you and only one takes your shells with it.
- **Opening a new window is a bindable action** (#793, from @jerryokk's #710).
Registered globally as well as on the render root, since with the tray icon on
— the default — closing the last window retires to the tray and leaves no
window to dispatch it.
- **The pointer can take a range of diff lines and copy it** (#794, requested by
@LittleSource in #721). The selection is keyed on the row's file and id rather
than a flat list index, so collapsing a file above the selection does not
re-point it.
- **A titleless tab is named after its working directory** (#792, requested by
@rrpolanco in #740).
- **The tab whose pane is zoomed says so** (#782, reported by @rrpolanco in
#752).
- **The host behind a connected tab can be edited from it** (#801, from
@junyi-deep's #438).
- **TraeCode CLI support** (by @ayamir in #807).
### Changed
- **The sidebar has a text hierarchy, and colour is on state.** Every line sat
at the same 4.5:1 grey — tab title, branch line, group header, search
placeholder — so the only things that stood out were twelve identical agent
discs and twelve copies of the same `+94 −26`, neither of which says which tab
matters. Titles rise to a 7:1 floor with a cap that keeps the selected label
its step above the rest; captions stay resting. The rail gets its own
selection ladder, leaving the window's signed-off rung untouched. Diff counts
render in a resting ink — same hue, blended toward the caption, walked back to
the 4.5 floor where needed. A group whose rows all share one branch and diff
says so once on its header, and rows with no status yet do not vote, so ⌘T no
longer flips the group twice while the poll comes back. The workspace switcher
card takes the same treatment: badges become words in the caption ink, only
"taken over" keeps its warning colour, and the keyboard cursor takes the rung
the ladder set aside for it instead of sitting one step under a hovered row.
- **The window's buttons show only under the pointer.** The new-tab, sidebar,
right-panel and app-menu tiles were on screen at all times, so a window
resting at the edge of the eye carried four buttons nobody was reaching for.
Each group now paints only while the pointer is over the bar it belongs to.
The window mark beside them stays put — it identifies the window rather than
doing anything. The tiles keep their place in the layout and only lose their
paint, so revealing a group never shifts what is beside it. With the detail
panel open the strip's two tiles stay drawn, because they then stand over the
panel's own header, whose tab tiles are painted whenever the panel is.
- **The inline controls are flat, and a tooltip's chord looks like a chord**
(#803). Every field and button carried a faint lift that nothing else here
has; this chrome separates surfaces with low-contrast fills and hairlines, so
a control sitting a millimetre above the panel was the one place claiming
depth. Panels that really do float keep their shadow. A chrome tile's shortcut
now goes in the tooltip's own key-binding slot — set apart on the right, a
size down, in the caption ink — instead of being pasted into the label to read
as one odd sentence. Settings gets one field width at 260px rather than three
picked where they were written, and the Program row's shell picker stops
drawing a fill that met the field's border top and bottom and looked like a
patch stuck over its right end.
- **The right panel's title row keeps its tiles and underlines the current
tab.** The row hid its two trailing tiles until the pointer entered it while
the three tab tiles beside them were always drawn — a row that grew two
buttons on hover. The current tab is said with a bar under the glyph rather
than a fill, because the fill was the same grey the hover state paints, so the
lit tab and the tile under the pointer read as the same thing.
- **A pane's frame is never queued behind the grid lock.** One UI thread paints
every pane in every window, and the thread holding the grid lock is the pane's
own reader part-way through feeding a batch of output into the emulator, so
waiting for it wired one pane's write speed to the frame rate of the whole
window. A frame that cannot have the lock paints the one before it instead —
unfair rather than queued on purpose, since a painter that queued would make
the reader wait for a frame it is not going to get anyway. Each pane therefore
owns its own cell buffer rather than sharing one scratch buffer, and the
keyboard context and selection flag read frame-cached copies rather than
locking once per caller.
- **The diff overlay draws its patch as a virtualised row list** (#799). The
overlay built its whole patch as a nested element tree on every frame — a card
per file, a header per hunk, six elements per line — so a few hundred lines of
diff rebuilt tens of thousands of elements tens of times a second and the
window stalled. It is one row per line now, built only for the rows on screen,
and a change to one file splices just the rows it touched rather than resetting
the list and losing the scroll position. The rows below the fold are counted at
the 19px both views already give a line of a patch, so the scrollbar stops
reading an 800-line patch as one viewport. The cards cannot survive that
flattening, so the rows take the source control panel's own measurements
instead of gpui-component's default container language.
- **A frame's header and payload go on the wire in one write** (#797, from
@ivydt08's #713).
- **A directional pane move remembers where it came from per pane, not per
direction** (#781, reported by @rrpolanco in #738). The per-direction array
meant a two-step walk clobbered the first step, so Left, Left, Right, Right
ended in the wrong pane.
### Removed
- **The Info panel's CONVERSATION outline is gone** (#703, #759). The list of an
agent's turns, and the click that scrolled a pane back to where one started,
are both taken out, along with the anchors the client kept for them. The OSC
777 events the hooks send still drive the tab's status dot; nothing else read
the rows the outline was built on. Output batches now split for one reason, so
a replayed snapshot parses in a single pass again.
### Fixed
- **A line the shell is still holding is submitted as itself** (#800, from
@junyi-deep's #433). The held seed is adopted at the editor's own doors rather
than at submit time, so a recalled history entry, a ctrl-U, a ghost suggestion
or a completion is no longer glued to the front of the gap text. It also
closes a paste-provenance hole: releasing a hold pushed its contents into the
typeahead record as plain text, dropping the paste mark, so a paste made
during a gap that outlived the hold window came back looking typed and was
submitted raw through the shell's binding table.
- **A plain single line is submitted as typed, not as a paste** (#790, reported
by @failable in #660).
- **A pane notices it came home from ssh without waiting for output.** The probe
that clears a pane's remote context only runs when the reader has bytes in
hand, and the prompt a shell draws after a command is the last output a pane
produces until the user types again — so an `ssh` that exited inside the poll
interval left the pane reporting itself as remote indefinitely. Most visibly ↑
read the remote history list, which for a host with no history of its own is
empty, so ↑ appeared dead until some unrelated output arrived. A prompt mark
that survives the foreground suppression is the shell saying the command is
over, so the probe runs right then. Switching history scopes also dropped the
list it was leaving; each scope's list is parked now, capped at four.
- **macOS notifications stop polling Notification Center from the UI thread.**
The crate behind them noticed a click by parking the sending thread and adding,
per outstanding notification, a repeating half-second timer on the main run
loop that made a synchronous XPC round trip. A banner nobody clicks stays in
Notification Center, so its timer never went away: sampled with nine
outstanding, a fifth of the UI thread was inside that XPC and every window
juddered. The click now arrives through a delegate of our own and nothing runs
on the main thread until the user clicks. The identifier carries the pid too,
so a banner left over from a previous run reveals nothing.
- **A ligated run stands over its own cells** (#785, reported by @rrpolanco in
#751), and every ligature feature is named off rather than just `calt` (#788).
- **A solo glyph stays inside its cell when the next one is taken** (#783).
- **A shift-punctuation chord folds into the key the platform reports** (#784,
reported by @rrpolanco in #750). Only half fixed: the US glyph table means
secondary-shift-`]`/`[` stay unpressable on German, French and Nordic layouts.
The control-code guard runs over the folded spelling too, so ctrl-shift-2 no
longer installs ctrl-@ beside it.
- **A machine whose profile is gone is named, not spelled as a UUID** (#786,
reported by @shihuaidexianyu in #485).
- **A repository is keyed by one spelling of its root** (#796).
- **A pane's paths are read in its own host's spelling** (#795).
- **The pointer can finish a Ctrl+Tab gesture the keyboard started.** Letting go
of Ctrl over the workspace list slammed the panel shut and picked a tab, so
switching workspaces by hand — the thing the pointer was on its way to do —
was unreachable. A release with the pointer on the card but off the tab column
now drops the hold and leaves the panel up; over the tab column it still
commits. Two macOS consequences of holding Ctrl go with it: the search box no
longer answers a mid-gesture click with Cut/Copy/Paste, and a tab row picked
with the mouse arrives on the right button.
- **A dismissing click is spent on the dismissal.** The switcher's scrim covers
the whole window, the tile that opens it included, and its mouse-down closed
the switcher and then carried on down to whatever sat beneath — for that tile,
straight back into the toggle that reopened it, so clicking it a second time
looked like it did nothing.
- **The workspace tile answers a hover.** Its only hover state was a fill the
palette derives one step off the surface, which on the rail is barely a change
at all, and the name, the monogram and the chevron each pinned their own ink,
so the button's hover never reached them. The text steps up to full strength
now, the way a group header's does.
- **An untouched rename box no longer names the tab** (by @hhdebb in #849,
reported in #848). The box opens holding the tab's label as rendered, so it is
never empty, and it commits on blur as readily as on Enter — so opening it and
clicking away stored that label as the tab's name, which stops following the
pane. The box is read against what it was seeded with; an emptied box still
clears the name, which is the only way to give a tab back to its pane.
- **An agent's status dot sits outside its disc** (by @hhdebb in #846, reported
in #845). The dot places itself with negative offsets so it overhangs the
avatar's edge, but it was a child of the element carrying the radius, so
everything past the circle was clipped along the arc and the badge came back
as a crescent.
- **A long branch no longer eats a group's name.** A header handed its overflow
to the name and the branch by flex shrink, which splits it in proportion to
what each asked for — so the longer string took the smaller cut and a heading
came out as `DEL…` beside thirty characters of branch. The branch takes what
it wants up to half the line, the name keeps the rest above a floor, and each
is elided into its own share. A group of one row lifts its branch onto the
header too (#836), and outside a repo a row's working directory rides on the
title's line rather than growing a second one (#851).
- **A folded sidebar group hides its active row too** (#806). A fold left the
active tab's row on screen, so folding the group you are working in drew a
shut chevron with one row hanging under it and a header counting rows that
were not there.
- **A custom group stays out of the workspace subject path.** The window titles
itself after the last component of the group most of its tabs are in, and a
custom group is a name rather than a path, so a hand-grouped workspace would
have been titled `custom:work` and one grouped as `work/urgent` chopped to
`urgent`.
- **The agent disc is painted solid again.** The resting tint from the hierarchy
work read as a disabled tab rather than a quieter one: a column of 16% discs
looked like a list of agents that had been switched off, and the brand hue is
how the eye tells a Claude row from a Codex row before it reads either title.
- **An agent's mark has one colour, not one per draw site.** The tab strip and
the tray icon each decided it on their own, so TraeCode came out green on a
tab and white in the tray. The colour lives on the agent now and both sites
read it.
- **The floating notices stack, and the forms have their keyboard back.** The
remote input notice and the ssh status strip both placed themselves at the
same spot, so a remote workspace whose ssh link had also dropped drew them on
top of each other; the anchor is a column now. The managed port-forward form
had no keyboard contract at all — no Return, no Escape, and it opened cold.
The four sftp edit forms took the focus into a box they owned and dropped the
box without handing it back, so naming a folder and pressing Escape left the
caret on an element that had stopped rendering. And `Override` on the
changed-host-key sheet — the one control that can accept a key that no longer
matches — carried no colour at all; it greys until "yes" is typed and then
goes red.
- **The right panel's tab underline reaches the rule that closes the row** on
Windows and Linux, where the wrapper around the tiles is only as tall as a
glyph, so the bar floated a few pixels above the line.
- **A seat that is coming back is waited for during a reap.** A seat is not free
the same instant its holder is confirmed dead: the kernel releases the lock
while tearing the process down, and a descriptor a `fork` left behind holds it
a moment longer. One `EWOULDBLOCK` used to end the attempt and nothing ever
revisited the file, so a dead pid stayed in it for good.
- **The Ports panel names the right machine.** Its fallback said "This machine's
tty7-server is too old", which reads as the local one; the server that cannot
answer is the far side's.
- **Turning off mouse reporting says what it costs** (#780).
- **The lockfile edges #799's merge walked back are restored** (#802). The merge
re-resolved `Cargo.lock` and pointed twenty consumers at older copies of
dependencies already in the tree. No `version =` line moved, so the change was
invisible to the usual scan of a lockfile diff.
## [26.9.1] - 2026-09-07
### Fixed
- **A remote server that will not start now says why** (#774). A remote
workspace could sit in a loop nobody could get out of: every reconnect failed
with "started but nothing was answering on the control socket after 15s", the
strip showed a copy bar frozen at 100%, and no button was offered. A daemon
whose control listener would not open logged one line and kept running — and a
running daemon holds the single-server lock, so every later start stood down
at once and every probe failed, forever. Whether something else is serving is
now settled by connecting rather than by reading the errno, and a daemon that
cannot listen exits. The reason is kept too: the remote daemon's output and
exit status land beside the binary, stamped with the launch's own nonce so a
restart never reads the outgoing daemon's status as the incoming one's, and a
start that has already failed no longer waits out the full timeout. On the
window side, an automatic reconnect retires its install progress instead of
drawing an install still in flight, and a long error no longer stretches the
status card past the window and takes the retry button off screen with it.
- **A stale pane socket no longer stops every later daemon** (#779). A daemon
that died without unlinking its Unix socket left a file `bind` refuses, so the
client launched a daemon, it exited on the bind, and the client launched
another — forever. The removal is now decided by the single-server seat rather
than the pidfile: holding the seat means nobody else can be serving that config
dir, so anything still at the endpoint belongs to a process that is gone.
Where there is no seat, a socket that answers is refused rather than removed.
### Changed
- **The control dialect is v9.** v8 added the project verbs; this build takes
them back out and speaks v7's messages again, message for message — but the
number does not go back with them. v8 is deployed, and a number that moves
backwards stops being an identity: a 7 on the wire would mean either "before
projects" or "after them" depending on which build put it there, and the
handshake has nothing but the number to tell the two apart.
## [26.9.0] - 2026-09-04
### Added
- **Remote programs can copy images to the local clipboard over SSH.** A saved
host can opt into OSC 5522 clipboard writes under **Advanced → Security →
Remote clipboard images**. PNG, JPEG, GIF and WebP transfers are decoded out
of band, capped at 16 MiB, validated before they reach the system clipboard,
and never enter scrollback or replay after reconnecting. The permission is
off by default; clipboard reads and SVG writes remain unsupported.
- **Documents dock beside the terminal** (#625). Opening a file, toggling the
code panel or opening a diff no longer covers the workspace: the document
takes a column to the right of the terminal — half the space between the
sidebar and the right panel by default — and the pane you were reading stays
visible and typeable underneath none of it. Reviewing a file while an agent
talks stopped being a toggle loop. Drag the divider for any width, double-click
it to cycle a third, a half and two thirds, or use **Document: Third / Half /
Two-Thirds Width** in the palette. Right-click the document's header for
**Fill window** — the old overlay, unchanged, and per tab, so a file read
over the whole window in one tab leaves the agent beside its own in the next.
The terminal keeps its floor through all of it, and a window too narrow to
seat both fills for that file only, without changing what any tab chose. New
in `config.json`: `document_ratio`, and `document_layout` for what a fresh
tab starts as.
- **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab
by its chip or by its sidebar row, out over the panes, and it lands where the
highlight says — the same reading as dragging a pane, minus the middle, which
for a tab means "split this pane the way it is longest" rather than "trade
places". A tab that was itself split arrives with its panes still arranged the
way you left them and takes one share of the row or column it joined. Nothing
restarts on the way over: a shell mid-command, an SSH session, an agent
halfway through a turn all carry on, and only the tab they were in goes away.
Picking a tab up no longer switches to it, so the tab you drop into is the one
you were already looking at; a plain click still switches.
- **And back out again: a pane dragged onto the tab bar becomes a tab of its
own** (#621). Take a pane by its grip up to the strip — or out to the sidebar,
wherever the tabs are — and a caret says which two tabs it would go between.
The last pane in a tab is offered nothing, being a tab of its own already.
- **Give the prompt back to the shell** — a new **Settings → Input → Prompt →
Prompt editor** switch (`prompt_editor` in `config.json`, on by default).
Turned off, tty7 stops editing the shell prompt: every keystroke there —
printable keys, arrows, IME commits, paste, <kbd>⇥</kbd> and <kbd>⌃ R</kbd> —
goes straight to the PTY, so zsh's ZLE, bash's readline and fish's reader own
the line and the keys bound in a dotfile behave exactly as they do outside
tty7, history traversal included. Previously the only way to get there was to
hide the shell's own name from tty7 so integration never armed. Shell
integration is untouched by the switch: prompt boundaries, working directory,
exit codes, notifications and `tty7 procs` keep working. Tab completion and
history search are menus tty7 opens inside that editor, so both grey out
while it is off rather than sitting there doing nothing. It reaches open
panes immediately, and a half-typed line is handed to the shell rather than
dropped on the way over.
- **A host can be proved without spending a tab on it.** **Test** in the SSH
host form dials the host exactly as Connect would — proxy, jump host, host
key and authentication, all on the daemon — and reports back in place:
`Connected and authenticated in 640 ms`, or what the handshake stopped to
ask for (a password, a key passphrase, a keyboard-interactive answer, a host
key nobody has accepted yet, or one that is not the key the server gave
before), or the failure verbatim. A test never rides an existing connection
— one would answer for the credentials *that* connection was made with, so a
password typed wrong would come back green — and it never enters the
connection cache, so it leaves nothing open and holds nothing up. An edit to
the form drops the answer, which was about the host as it was typed a moment
ago (#438).
- **The host form is reachable from wherever the machine is on screen.**
Right-clicking a machine in the workspace switcher offers **Edit Host…** for
a saved host, or **Save as SSH Host…** for one reached by address or by a
`~/.ssh/config` alias; the switcher's **Add SSH Host…** now opens the form
instead of the settings list you then had to find `+` on. A connection
dialled by hand and worth keeping becomes a saved host from the palette —
**SSH: Save Connection as Host…**, prefilled from the live session. The one
thing that cannot come along is an ad-hoc `-J` hop, and that is said out
loud rather than saved broken (#438).
- **A coding agent's conversation is an outline, and a way back into it.** The
Info panel grows a **CONVERSATION** section: one row per turn, the prompt's
first line as its label, a dot that says whether the turn is still running.
Click a row and the pane scrolls so that turn's prompt is the top line — a
long agent session in a terminal has never had a way back to "what did I ask
an hour ago". It rides on the OSC 777 the hooks already send for the tab's
status dot, so it costs a cut only when an agent event actually arrives, and
it works wherever the agent runs — over ssh, in a container, in a remote
workspace — rather than only where a transcript file happens to be readable.
Reattaching to a pane rebuilds the outline from its own replayed history. A
turn that began on the alt screen is listed but not clickable, because there
is no scrollback behind it to return to. Agents whose hooks do not report
prompt text (Codex, Copilot, Grok) are left out rather than drawn as a column
of anonymous dots.
- **The interface font is configurable** (#761). **Settings → Appearance →
Typography** now carries an interface font family beside the terminal one, so
the chrome can be set apart from — or matched to — what the panes are using.
- **A coding agent's conversation gets an outline, and every turn is a jump
target** (#703). The right panel lists the turns it saw go by; clicking one
scrolls the pane back to where that turn started. A turn under a full-screen
agent has nothing to land in, and the row now says so on hover rather than
drawing a link that does nothing (#759).
- **A remote workspace relinks its own dead panes** (#757). Panes whose SSH
route died come back on their own when the link returns, and the tabs are
named after what is actually running in them instead of keeping whatever the
snapshot said.
- **Saved SSH hosts are one click from the New Tab button** (#647), and an SSH
pane names itself after its host, reaches the host form from wherever you
are, and can test a connection before you commit to it (#566).
- **Nushell panes get OSC 7 cwd tracking and OSC 133 prompt marks** (#637), so
the sidebar follows a Nushell pane's directory and command marks work the way
they do under the other supported shells.
- **The shell's own line editor owns the prompt** (#633). tty7 stops
second-guessing the line being edited and hands the row to zsh, fish, bash or
Nushell, which is what makes their completion, history search and multi-line
editing behave the way they do outside tty7.
- **A tab whose cwd is not a repository groups by its folder** (#631) instead of
falling into an unsorted pile at the bottom of the sidebar.
- **The wheel-zoom modifier is configurable** (#676) — for anyone whose mouse or
trackpad already spends Ctrl+wheel on something else.
- **`tty7 server restart` replaces the server in place, keeping every session**
(#669). The old stop-and-start is still there behind `--hard` for when the
process really does have to go.
- **Linux updates install in app** (#306, #652). A verified AppImage release is
downloaded, checked and swapped in without leaving tty7, the way macOS and
Windows already worked.
- **An all-users Windows install updates through a single UAC prompt** (#562)
rather than one per file it has to replace.
- **A file path printed by a program opens in tty7**, resolved on the host the
pane is actually on (#568) — a path from an SSH pane opens the remote file,
not a local one that happens to share its name.
- **The workspace switcher is a flat list with a create form**, and connecting
to a machine syncs its workspaces at that moment rather than whenever the
next poll came round (#616).
- **SFTP opens remote text files in the built-in editor** instead of handing
them to whatever the OS thought should have them.
- **Hooks, resume and fork are wired for the CLI agents that support them**
(#666), and Kimi Code CLI is detected and driven like the rest (#694).
- **Four dark themes** (#663).
- **Closing the last window retires tty7 to the tray** rather than quitting it,
so the shells keep running and the next launch is instant (#639).
- **A `tty7-server` build is published for macOS** (#605), so a Mac can host a
remote workspace and not only connect to one.
- **↑ and Ctrl+P recall the last command that matches what is already typed**
(#768), instead of walking history from the end regardless of the prefix.
### Changed
- **The Info panel's `agent` row is gone.** It said `Claude Code · working`
beside a status dot — the same name and the same dot the tab and its sidebar
row were already wearing, two panels away from neither of them. The
CONVERSATION section below now says what that agent is doing in a form the
row never could, and the dot stays where it was learned.
- **A zsh or fish you gave your own arguments to is no longer injected into.**
Custom arguments have always been the line where tty7 backs off — the bash,
PowerShell and WSL setups checked for them — but the zsh and fish setups did
not, so a `fish` started with your flags had `-C <script>` appended to them
and a `zsh` had its `ZDOTDIR` swapped out from under its startup files. Both
now behave like the rest. **This turns shell integration off for a config
that sets `shell` or a `custom_shells` entry with `args`** — including the
`{"program": "fish", "args": ["-l"]}` the reference page used to print as an
example — so prompt marks, working directory reporting, command-finished
notifications, inline completion, <kbd>⌃ R</kbd> and per-pane history stop in
those panes. Drop the arguments to get them back, and the notice a pane
raises when integration never engaged now names this as a cause (#624, #629).
- **An SSH pane is named after its host.** A fresh SSH tab reads the host's
name, or its address when the host has none — every host imported from
`~/.ssh/config` arrives nameless, and a window full of them all read `tty7`
until the far shell happened to title itself. The name survives a title
reset and a dropped link (`prod-web — disconnected`), and a tab titled
`deploy@10.0.0.5:2222` is shown whole rather than cut down to `2222`: the
`user@host:` head is only a head when a path follows it (#438).
- **The host form's authentication row is a dropdown**, not a six-way
segmented control — it was the row that stacked first on a narrow page. And
the three proxy fields no longer read as independent: only the first one
filled is used, so the losing fields now say `Not used: Proxy command comes
first.` instead of leaving it to be discovered by connecting (#438).
- **A workspace reads its own name from the first frame, and stops changing
under you.** Every workspace a window creates is given a generated name —
`keen-marten` — and that is the name `tty7 ws ls` prints and `tty7 ws rename
keen-marten …` addresses. The window that created it was never told: a client
is left out of the deltas its own edits raise, and both create paths threw
away the name the machine sent back, so the chip showed the directory its
shells happened to start in. The GUI and the CLI gave two different answers to
"what is this workspace called", and the real name arrived later — at the
first daemon restart, rebuild or relaunch — looking like the workspace had
renamed itself. A window now learns the name along with the layout, so the
chip agrees with `ws ls` from the start. A workspace with no name, which is
what `tty7 new` leaves behind, still reads the directory it is working in, and
a name you chose yourself still wins (#604).
- **File links open in tty7 instead of leaving it.** A ⌘/Ctrl-clicked file path
now opens in the built-in editor, on the line and column the link named, and
the Files panel selects it and scrolls it into view; a directory link opens
the panel on that directory. **Settings → Terminal → Links → Open files with**
picks between the built-in editor, the OS file association and a command of
your own — anyone who had already set `link_file_command` keeps it.
- **A pane address may now be written without its `%`** — `tty7 pane ls
--json` prints bare ids, so `83` addresses the same pane as `%83`
everywhere an address is taken. One behaviour goes with it: a lone `tty7
send 83` used to type "83" into the caller's own pane and now reports that
it has nothing to send, the same as a lone `send %83` always did. It fails
loudly and never presses anything anywhere; to type a number as text, name
the pane too (`tty7 send %5 83 --enter`) (#538).
- **The seam between panes is lighter than the outline around a menu** (#771).
One hairline colour used to serve both, which made the sidebar, the right
panel and the document column read as boxes bolted together rather than one
surface. The two are derived the same way and floored differently now: a line
that is the only thing marking an edge keeps its weight, a line running
between two panes that already carry their own fills steps back.
- **The app's copy is shorter** (#663). Settings descriptions, prompts and
notifications lost the sentences that were restating their own titles.
- **`tty7 send` takes the bare pane id that `--json` prints** (#699), so an id
read out of one command can be pasted into the next without editing it.
- **The control dialect is v6** (#605). A daemon left behind by an older build
is detected at launch and offered a restart instead of serving a window with
no tabs.
### Fixed
- **A conversation row no longer offers a jump the pane cannot make.** Clicking
one under a full-screen agent did nothing at all: no scroll, no message,
nothing. The row had an anchor, so the panel drew it as a link — pointer,
hover fill and all — while the view refused the jump, because a pane on the
alternate screen has no scrollback to land in. Switching Claude Code into
`/tui fullscreen` mid-session is enough to produce it: the turns recorded
under the classic renderer keep their anchors, and every one of them goes
quiet at once. The two conditions now live in one predicate both sides read,
and a row that goes nowhere says why on hover rather than leaving grey text
to carry a meaning grey does not have. Jumping is still off while an agent
renders full-screen — there is genuinely nothing behind it — but the panel no
longer pretends otherwise.
- **In-app updates work again on macOS** (#708). Every "Update and Relaunch"
failed with `codesign did not report a designated requirement`, on every
build and both channels, with nothing a user could do but download the app by
hand. The updater compares the signing requirement of the staged app against
the installed one, and read `codesign -d -r-`'s answer off stderr — where
codesign puts only the `Executable=` header. The requirement is on stdout, so
the check could never match. Both streams are read now, and the parse is
split from the process call so a test can hold it against codesign itself
rather than against our belief about it.
- **A tree pull that has to be retried no longer ends with the window deleting
the tabs it was pulling.** A window told to rebuild itself from the machine —
a daemon back as a new process, a restart handoff that was refused, a remote
server restarted — abandoned that job the moment it had any tabs at all, and
in every one of those cases it does: the stale ones on screen are the whole
reason it was asked. So the rebuild silently did nothing, and worse, the
window went on claiming to speak for the workspace it had never read. Opening
one tab over it then diffed into "close every tab" and deleted those panes'
records while their shells were still running, with nothing left that could
reach them. The retry is now dropped only for a tab the user really did make
while the pull was out, and a window waiting on a rebuild adds to its machine
without pruning it until the pull lands (#579).
- **Installing an update on Windows shows a progress window.** The installer
ran `/VERYSILENT`, so from the app quitting to the new build coming up —
tens of seconds, longer under an antivirus scan — the screen held nothing
at all, and "clicked update, the app vanished" read as a crash. The
installer now runs `/SILENT`: still unattended, but Inno's own progress
window stays on screen for the gap (#600).
- **Orphan panes are visible in the GUI, and closable from it.** A shell
left running after its workspace went away — what an interrupted `tty7
run` leaves behind — showed up nowhere in the GUI; only the CLI's `tty7
pane ls --all` could see it, and only `pane close --orphans` could stop
it. The workspace switcher's local machine group now lists those
background panes with their owner and working directory, each with a
Close button (#596).
- **A launch that restores one of several windows says what it left behind.**
Quitting with several windows open and starting again restored only the
most recent one; the rest were marked detached — panes alive, nothing on
screen, the only trace a log line. The restored window now shows a
notification naming how many workspaces are still running in the
background and where to reopen them (#597).
- **`tty7 pane close %99` fails when no pane 99 exists.** The orphan path
hangs the pane up directly, and that kill is fire-and-forget — the daemon
never says whether it knew the pane — so a typo'd id printed
`{"closed":[99]}` and exited 0, telling a reaper script the leak it was
chasing was gone. Close now checks the id against the running-pane
registry first and reports the miss under `failed` with exit 1 (#588).
- **cd Here and Insert Path quote for the shell the pane runs.** Both used
to wrap a path with spaces in POSIX single quotes whatever the pane's
shell was, and cmd.exe — where a single quote is an ordinary character —
then split the path at its first space. The quote style now follows the
pane's shell: double quotes for cmd.exe, single quotes for PowerShell and
every POSIX shell (#593).
- **Remote path completion says what it's doing.** Tab-completing a path
on a remote workspace used to show nothing for the whole network
round-trip — a slow link read as a broken Tab key — and a listing that
failed ended in exactly the silence an empty directory ends in. A pill
over the pane's corner now says the listing is running, and a failed
listing reports its error there instead of vanishing (#585).
- **Seven hard-coded English strings moved into the language tables.** The
shell-integration notice, the pane titles a disconnected or exited pane
wears, the loopback forward's failure, the tray tooltip that lists running
agents, the cursor-shape choices, the command palette's empty-result hint
and the updater's install hint all used to render in English whatever the
UI language was; they now follow it, and the palette's hint no longer
suggests connecting over SSH in menus that have nothing to do with hosts
(#602).
- **A half-typed tab rename survives other tabs closing and the strip
reordering.** The rename box tracked its tab by index, so any unrelated
tab event forced it closed to keep the commit from landing on the wrong
tab — and even then, a reorder mid-rename left a window where the name
went to the tab that had taken the index over. The box now tracks its tab
by tree id: only closing the renaming tab itself ends the rename, and the
commit lands on the tab the box was opened on wherever it has moved
(#598).
- **A zoomed pane stays zoomed when you leave its tab and come back.** Zoom
was a window-level value that activating any tab cleared, so looking at
another tab and returning restored the split layout — while a zoom is a
tab's temporary view state, like its focused pane. It now rides with the
tab; the clears that genuinely reshape the layout (drag, split, close)
still stand, and a zoom whose pane exited while the tab was away does not
come back (#599).
- **Opening and closing the search bar no longer erases the grid
selection.** The selection that seeds the query is the thing being
searched for, yet opening the bar ran the same unconditional clear as
*changing* the query, and closing cleared it again — select text, press
Ctrl+F then Esc, and the selection was gone. The seeded selection is now
kept through the open, and closing keeps whatever selection the grid
holds; only an actual query change retires it, the discipline the output
rescan path already stated (#584).
- **Search highlights follow the text when the pane is resized.** A match
point is an absolute (line, column) against the width it was scanned at,
so narrowing a pane reflowed the text out from under every highlight until
new output happened to trigger a rescan — and a quiet local pane has none
coming. A column change now rescans immediately, with the output path's
discipline (the selection and scroll position are left alone); a
rows-only change reflows nothing and stays cheap (#586).
- **A mistyped "Start in" path is refused at save instead of silently
rerouting every new pane.** The custom path used to be stored unchecked,
and the daemon's picker then skipped it — not a directory — and started
each new shell in its own fallback directory, so "new shells don't start
in my project" read as a tty7 bug rather than a typo. Settings now marks a
non-existent directory in red and does not save it, and a hand-edited
`config.json` holding one gets a `log::warn!` naming the path at the
moment the fallback engages (#601).
- **`tty7 doctor` exits 1 when the server is unreachable.** Doctor is the
verb people run when something is not working, so an unreachable server is
*the* finding — not a row to exit 0 over while `tty7 doctor || alert`
never fires. The full table and JSON still go out, and stderr carries the
headline under `-q` (#592).
- **The `owner` field of `pane ls --all` is documented the same way in both
references** — the bundled skill reference still claimed the CLI stamps a
literal `"tty7-cli"` owner, the behaviour that was removed because an owner
names the workspace allowed to attach. Both now describe the workspace id,
or its absence while a pane is unfiled (#591).
- **A failed `tty7 wait` now says so on stderr even under `-q`.** Timeout and
"pane exited first" are structured exits, so they bypassed the anyhow path
that prints under quiet mode and left the exit code as the only evidence —
against the documented "errors still go to stderr". Both now print a
one-line headline to stderr, the discipline `pane close` already set (#590).
- **A timed-out `tty7 wait` now answers in the same JSON shape as a finished
one** — `matched`, `stale` and the agent session fields, plus
`"timed_out": true` — instead of a bare object missing the fields a
consumer's error branch was written against. The schema, including
`timed_out`, is now documented (#589).
- **Cancelling the amend confirmation no longer switches amend off.** The
toggle was cleared when Commit was pressed — before the "rewrite the last
commit?" prompt — so answering Cancel returned to a panel whose amend mode
had silently been dropped, and the next Commit created the brand-new commit
the user had just declined to risk. The toggle now switches off only when
the commit actually runs (#595).
- **The SCM panel's "discard all" confirmation no longer overstates what it
does.** The prompt asked to "discard every change in this repository" while
the operation has always left staged changes alone — it sweeps only unstaged
edits and untracked files. The prompt now says exactly that, in all three
languages (#594).
- **A local daemon that dies and comes back no longer leaves a window of dead
panes looking live** — from the client's side a killed daemon is
indistinguishable from one whose shells all exited at once, so the window
kept showing every pane with its last title, and the reconnect then pushed
that dead layout back up as the new daemon's truth. The control handshake's
instance id is now compared on every local reconnect — the same check the
remote path already made — and a changed instance rebuilds each local
window from the machine tree instead: tabs come back, and each pane is
restored from its scrollback snapshot with the "this is a new shell" banner
rather than left frozen mid-lie. The restart-server action uses the same
rebuild path (#553).
- **A file link in a remote pane no longer opens this machine's copy.** An
absolute path was checked against the local filesystem whatever the pane was
connected to, so `/etc/nginx/nginx.conf` on a server opened the one on your
laptop, silently. Paths are now resolved on the pane's own host, and a pane
running `ssh` typed into a local shell — where neither side can answer for
them — no longer offers file links at all. A file on another machine opens
in the built-in editor whatever **Open files with** says, since a local
`open` or editor command would land back on this machine's copy.
- **Relative paths resolve from where the work is, and from the repository
around it.** Detection followed the shell's kernel cwd, which an agent
working in a git worktree never updates, and only ever tried one directory —
so a path a build tool printed from the workspace root did not resolve from a
member directory. A path that matches nothing under either now says so
instead of the click doing nothing.
- **A broken config.json can no longer be silently replaced by defaults** —
a file that failed to parse was ignored with only a log line, and the next
write of any setting — dragging the sidebar divider, zooming the font with
Ctrl+=, saving anything in Settings — serialized the in-memory defaults
over it wholesale, turning one typo into the loss of every hand edit. A
load that fails to parse now keeps the file's contents beside it as
`config.json.corrupt` (the same quarantine `views.json` already had), and
the stand-in defaults carry a mark that makes `save` refuse to run, so
nothing writes until the file parses again. The hot-reload watcher tells
"broken" apart from "absent" and keeps the settings the app is running on
instead of swapping defaults in mid-session, and both the startup and the
reload path say what happened and where the copy is. A file that simply
cannot be *read* logs a warning now too — it used to fall to defaults with
no trace at all. (#537)
- **A mistyped `tty7 send` address no longer types itself into your own
pane** — `tty7 send %3x --key C-c` used to degrade the unparseable `%3x`
into text aimed at the pane the caller was sitting in, then deliver the
Ctrl-C there too, interrupting whatever was in front of them. A `%`
followed by a digit that still fails to parse is now the address error it
looks like, while text that merely starts with `%` — vim's `%s/foo/bar/`,
`%!sort` — keeps typing as given, as does anything unmarked that is not a
plain number (#538).
- **The `ws rm` docs now say the panes are hung up, not orphaned** — the CLI
reference claimed removing a workspace leaves its panes running as orphans
to be found with `pane ls --all`, when the code has hung them up since the
command exists; only a *failed* hang-up (which `ws rm` reports by pane id)
leaves orphans behind. The site reference, the bundled skill reference, and
`ws rm --help` all read the same way now (#539).
- **The Info panel's agent row reads one pane, not a splice of two** — the
name came from `tab.agent` (whichever leaf has an agent) while the status
came from `tab.agent_status` (the most urgent across the whole tab), so a
split tab running two agents could show one pane's name beside the other
pane's state. The row now takes both from the detail pane when it has an
agent, and otherwise from the tab's most urgent agent pane — so the row
still holds while focus sits on a plain shell, and its two halves always
describe the same pane (#543).
- **Windows paths in the Info panel shorten to their leaf again** — the cwd
row split on `/` only, so a backslash-spelled path (any agent-reported cwd,
a cmd pane, the shell-integration-off case) elided its *tail* and hid the
directory's own name. The split now takes the last of either separator,
and the `~` shortening — shared by the Info panel, the tab strip, and the
home picker — reads `USERPROFILE` as well as `HOME` and compares with
separators normalized and case folded, so a `C:/Users/…` pane shortens
under a `C:\Users\…` home (#544).
- **A hand-edited keybinding takes effect without a restart.** The config
watcher reloaded everything except the keymap, so a `keybindings` edit
showed up in the settings list off the live global while the key itself
stayed dead until the app restarted. The watcher now rebuilds the keymap
when the binding config actually changed — `(keybindings,
keybinding_preset, prefix)` — so the app's own `save()`, which a sidebar
drag or a palette open triggers, does not churn it. The rebuild also
replaces the map instead of appending to it, so rebinding no longer leaves
a retired copy of the whole table behind for every keystroke to walk. A
config.json that does not parse keeps the keys it was already dispatching,
since the reload that fails never reaches the rebuild (#548).
- **The font-size and line-height steppers no longer push a hand-set value
the wrong way** — the settings steppers and the `Ctrl+=`/`Ctrl+-` keys
clamped to a narrower range (font 6–48, line height 1.0–2.0) than the
config file allows (4–256, 0.5–4.0), so `font_size: 50` shrank to 48 on
"+" and wrote that back, permanently changing a value it only meant to
nudge. The steppers and `sanitize` now share one range, defined next to the
validation. The scrollback and notify-threshold preset rows got the matching
fix: a value between two buckets no longer lights up the nearest one
(`scrollback_limit: 5000` highlighted "10,000", and clicking that cell
silently overwrote it) — it shows a "Custom (5,000)" cell that names the
real value and is not a button (#550).
- **A refused in-place daemon restart no longer strands every pane it
promised to keep** — Restart Server clears the window before attempting the
handoff, and when the handoff failed the window simply stayed empty with an
error on the home page. The daemon was still running and serving those
panes, but every restore path is driven by the machine tree, and the next
sync of the emptied window diffed into "close every tab" against it —
deleting the pane records under shells that were still alive, or the whole
workspace at once if the user just closed the window, right after the
dialog had promised nothing would be interrupted. A failed restart now
drops the dead link and pulls the layout back from the tree, reattaching
the living panes; where the failure really did take the daemon away, the
missed pull leaves a rehydration debt instead, which is what stops the
empty window from being pushed up as the layout. (#554)
- **Settings' Shell Arguments field now splits like a command line, and quotes
on the way back.** The field split on raw whitespace, so `-c "echo hi"`
reached the new shell as four argv fragments with the quote characters still
attached, and the damage was invisible until the pane misbehaved. The round
trip was lossy in the other direction too: a perfectly legal
`"args": ["-c", "echo hi"]` in `config.json` refilled the field as
`-c echo hi` and re-committed as three argv the moment it lost focus, without
the user typing a thing. Quoted text is now one argument, the refill quotes
the arguments that need it, and a value whose quotes do not close is refused
with an explanation under the input rather than saved as fragments. A path
spelled with backslashes still means itself. (#551)
- **`tty7 send --enter` now presses Enter when there is nothing to type** —
`--enter` is shorthand for `--key enter`, but it was never counted as a key,
so `tty7 send %42 --enter` answered "needs TEXT … or a --key to press"
instead of running what pane 42 already had typed. It counts now, with an
address or without one (`tty7 send --enter` presses Enter in your own pane).
An *unmarked* id is deliberately left out: `tty7 send 83 --enter` reads as
much like typing "83" where you are sitting as like pressing Enter in pane
83, so it stays a loud error that names both spellings (`send %83 --enter`,
`send %PANE 83 --enter`) rather than quietly retargeting the keystroke
(#581).
- **A `~` in a path now belongs to the machine that path is on.** The Info
panel's cwd, the tab strip and sidebar titles, and the switcher's workspace
and tab rows all shortened against *this* machine's `$HOME` whoever the path
belonged to, so a server sitting in `/home/deploy/app` read as `~/app` on a
laptop that happens to log in as `deploy` and stayed spelled out on one that
does not — the `~` naming the wrong machine either way. Each of those rows
now measures a path against the home its own host reported when the link
came up, and a path on a machine nothing here has a link to — or one a pane's
shell has `ssh`'d away to — is shown in full rather than against a home that
is not its own. (#580)
- **Push at a detached HEAD no longer fails silently** — the sync tile claimed
"Publish Branch" (the one thing a detached HEAD cannot do) and swallowed the
click, and the key binding, palette and "Commit and Push" follow-up died in
the same guard just as quietly. The tile and the branch menu's Push item now
disable themselves with a tooltip that says why, every other path gets a
toast naming the reason, and an unborn branch — no commits to send yet —
gets its own answer instead of sharing the detached one's silence. (#545)
- **A refused commit says why, not always "Nothing to commit"** — committing
from the palette or the key binding with staged work but a blank message was
answered with "Nothing to commit", which sends the user staging files they
already staged; the toast now carries the plan's actual reason ("Write a
commit message first"), the same words the panel's own button shows on its
tooltip. (#546)
- **Terminal pop-up menus no longer leak clicks into the grid behind them** —
the completion menu and the reverse-search menu (both the floating panel and
the input-bar row) inserted no hitbox of their own, so a press on one fell
straight through to the terminal: it cleared whatever was selected and
dragged out a new selection, merely moving over a row underlined the text
beneath it, and a Ctrl+click opened the link the menu was covering. All
three occlude now, so a press on a menu stops at the menu. (#541)
- **A file link that fails to open says so** — clicking a file path whose
opener is missing, or whose `link_file_command` template expands to nothing,
used to fail into a logfile line and nothing else; the click now raises the
same kind of toast a failed image upload does, naming the path and the
error. (#542)
- **A restart no longer asks a second time about a server that is already
gone** (#770). The record behind the "Restart Server?" prompt could only ever
accumulate: the control link re-armed it on every failed reconnect, including
in the seconds spent reading the dialog, so restarting fixed the daemon and
not the record and the next window asked again. A probe that finds the daemon
ours now clears it, on both the handoff and the stop-and-spawn paths, and a
verdict about a daemon that has since been replaced is dropped rather than
written.
- **A split pane's cursor reflects which pane actually has focus** (#736) —
both cursors could render active at once, so neither said where typing would
land.
- **The agent unread badge reads live focus** (#758), instead of clearing on a
pane that was merely visible.
- **Clicking a sidebar row's change counts activates that row before opening
its diff** (#706, #729), so the diff that opens is the one whose numbers were
clicked.
- **A window arriving at a workspace no longer claims to speak for panes it has
not read** (#716, #728) — the state that let a fresh window prune tabs it
knew nothing about.
- **Every SSH channel tty7 abandons is closed before the server does it**
(#715, #727). Leaked channels accumulated over a long session until the
server's per-connection limit refused the next pane.
- **A restored tab keeps its name** (#725). The title was left out of the
snapshot, so a tab restored after a restart came back labelled by its shell.
- **An emoji that overflows its cell gets the room instead of being shaved
flat** (#707), and a regional-indicator pair is shaped as the one flag it is
rather than two letters (#686, #691).
- **An orphan pane's Close button stays on screen** (#712) — the one control
that could clear it was the first thing to scroll away.
- **A stalled remote link no longer blocks the UI thread** (#709). A server
that stopped answering froze the whole window rather than the one workspace
waiting on it.
- **Windows paths are quoted correctly, "Checkout to…" is wired up, and the
Spawn reply is bounded** (#705).
- **The input bar measures columns with `unicode-width`** (#704) instead of a
hand-rolled table that disagreed with the terminal beside it.
- **A window that draws its own title bar is not framed again by the
compositor** (#679, #683) on Linux.
- **Reveal and copy normalize path separators on Windows** (#680).
- **The PTY gets back the Ctrl chords tty7 was eating** (#684), and Ctrl+V
reaches a full-screen program on the alternate screen instead of pasting over
it (#677, #682).
- **A silent Attach fails instead of hanging, and a rebuild that cannot put the
tabs up holds them** (#673, #681) rather than dropping them on the floor.
- **A daemon that lost both its names is still found and reaped** (#671), and
one lingering after quit-and-stop stays findable rather than stranding its
shells (#655).
- **Hidden panes stop repainting the whole window** (#670) — a background tab
under a busy program cost the same frames as a visible one.
- **The code editor keeps scrolled-out text off the line numbers**, and stops
painting its gutter and current line in the stock syntax theme's colours
(#636).
- **Ctrl+W in the editor kills one path component at a time** (#658, #659).
- **The sidebar's tab rows and workspace head resolve to a width** (#662)
instead of collapsing at certain panel sizes.
- **A restored screen stays out of ConPTY's viewport on Windows, and Restart
Server no longer crashes the window** (#657).
- **The SCM sync tile stays on the branch row at any panel width** (#650), a
branchless push is answered, and a refused commit says why (#545, #546,
#576).
- **The SSH menu keeps a menu's shape** (#649), the password prompt's scrim
covers the whole window, and a failed reconnect names the machine (#645).
- **The in-app notification is restyled to tty7's own visual language** (#646).
- **A command that is over in a blink no longer flashes across the tab.**
- **Settings gets its scroll range back**, and the scrollbar is held off the
window corner.
- **A remote server stops cleanly on machines with no `/proc`**, and the install
shows on the strip (#627).
- **A pane seeded by a window is recorded in that window's own mirror** (#628),
and a workspace switch no longer rereads the app mid-update (#618).
- **A resize defers to the daemon's Size echo on remote routes too** (#632), so
a remote pane reflows once rather than twice.
- **Shell integration is not injected into a zsh or fish the user gave
arguments to** (#629) — the case where injecting silently changed what the
shell was asked to do.
- **The new-worktree prompt is a window-level modal** (#626).
- **A deleted remote workspace is scrubbed from the listing snapshots** (#622).
- **A server creation interrupted by an update is finished**, and the note that
answered it is retired.
- **A new workspace keeps the name it was given** (#619).
- **A dialect refusal has a way out instead of a retry loop** (#617).
- **A window is told the name the machine gave the workspace it made** (#604,
#613).
- **A Unix pwsh pane's title names the user, host and home** (#611).
- **The right panel's tab icons redraw, and the tree glyph sits on the tile
ladder** (#578); the plus is drawn on the same optical bound as the rest of
the set (#577).
- **The tab strip's status dot is ringed in white on dark themes.**
- **`tty7 send --enter` presses the key it is shorthand for** (#581, #606).
- **In-app updates check a Mach-O's signature by codesign's exit status**
(#696), and CI asserts every Mach-O in the macOS bundle is the architecture
it ships as (#687, #692).
- **Nine UX fixes across the diff overlay, layout, settings and pane spawn**
(#623), and nineteen more low-severity ones (#584–#602, #615).
## [26.8.3] - 2026-08-12
### Added
- **A full Source Control panel** — the half-finished Changes tab is now a
working-tree panel with a real index. Files are grouped into merge, staged,
changes and untracked, with a file appearing in two groups when its status
says so; the rows stage, unstage, discard and open a diff, and a commit can
be written, amended, pushed, pulled or synced from the panel itself. The file
tree carries git decorations beside every entry, folded once per refresh
rather than once per frame. A diff opens from three sources — working tree,
staged, or a commit — behind a unified/side-by-side toggle that names which
one it is showing. Under the file list sits a commit history drawn with
lanes, paged as you scroll, with lane colours derived from the theme's own
`ansi16` and walked to a contrast floor so they stay apart on every builtin.
Anything that can lose work asks first, and the data layer is the single
place that decides what counts as destructive. **The control protocol does
not change** — an unmodified remote server serves all of it, push, pull and
fetch included, because the deadline that would have cut a long network
operation short was only ever enforced client-side. Non-UTF-8 paths are a
documented read-only limitation: those rows carry a tooltip rather than
sending git a pathspec nobody can spell. (#424)
- **The interface has a font size of its own** — `ui_font_size` in
`config.json` scales the whole chrome, and every window's root sets it, so
sidebar, panels, menus and dialogs move together. It defaults to gpui's own
16, so an existing config renders exactly as before. The terminal grid is
unaffected: it stays absolute pixels off `font_size`, which means a display
that is not Retina can finally have readable chrome without touching the text
in the panes. The detail panel, which had carried a private run of pixel
sizes that put its primary text at the size everything else uses for
secondary text, is back on the same ladder as its neighbours, with mono a
notch under the sans it pairs with.
- **A scrollback scrollbar down the right edge of a pane** — a pane's scroll
position lives in rows of scrollback rather than pixels of laid-out content,
so it had no handle to give a scrollbar. It now implements one over the grid
and draws the same bar the sidebar and every list already use — same theme,
same fade-out. The bar never drives the terminal: it records the row it
wants and the next render applies it, clearing the sub-line remainder and
cancelling an in-flight smooth scroll on the way. Scrollback piling up at the
live edge is deliberately not reported, so a pane printing a build log does
not hold a thumb on screen for as long as the output runs; every other
change passes through, including the history shrinking. (#480, #432)
- **Zoom a pane's font with the platform modifier and the wheel** — holding
Cmd (Ctrl off macOS) and scrolling over a terminal resizes the font instead
of the scrollback, which is what you reach for when showing a pane to someone
else. A wheel detent is one step whatever the platform bills it as, and a
trackpad accumulates until the fingers have travelled three lines, so a flick
does not run the font end to end. Steps go out as the existing
increase/decrease actions, so the clamp and the saved setting stay in one
place.
- **The command palette previews a theme while you arrow through the list** —
picking a theme used to close the palette, so seeing what one looked like
meant reopening it and retyping the search for every candidate. The
highlighted row is now applied to the running window and the picker stays
open: Return keeps it, Escape or any other way of closing puts the previous
theme back. A preview only touches the in-memory config, so arrowing through
the list never writes `config.json`, and the picker opens on the theme
already in use so opening it changes nothing by itself.
- **The palette matches commands in every locale, not just the one on screen**
— the filter only ever saw the label it was rendering, so a window running in
Chinese answered "no matching commands" to `theme`. Each entry now carries
every locale's wording of its key, plus the stable command id, as hidden
search aliases: built once with the entry, never rendered, and scored just
below a hit on the visible label so what you can actually read still comes
first.
- **fish history joins the Ctrl+R menu and inline completion**, locally and on
remote hosts. `fish_history` looks like YAML and is not — fish escapes only
`\` and newline and quotes nothing, so a YAML reader silently drops every
record holding a `: ` or a leading `[` and truncates anything with a ` #`,
which is most conventional-commit messages. It is read with a line scanner
shaped like fish's own reader instead. Each history file fetched from a
remote host now carries the name it came from, so the far end's fish records
reach the fish reader rather than arriving as literal `- cmd:` rows.
Multiline entries are skipped rather than half-recalled. (#421)
- **The daemon upgrades in place instead of killing every shell** — picking up
a new build used to mean stopping the daemon, and stopping the daemon takes
every pane with it: the pty master is a descriptor that process holds, so its
death raises SIGHUP on the shell, the agent and the half-finished command.
The daemon now rewrites itself with `execve` on handoff — same pid, same
children, same descriptors, same locks — writing what it knows about each
pane into a blob and clearing `FD_CLOEXEC` on the ptys first. The restart
Settings offers for a quiet moment is still there for the cases handoff
cannot cover. (#449)
- **The tab sidebar elides labels tail-first and explains itself on hover** —
a path keeps its leaf rather than its root, anything that is not a path keeps
both edges, and a hover card spells out in full whatever the row had to hide:
a renamed tab's real name, the terminal title behind a `Shell 3` placeholder,
the remote host. The card is decided by comparing what the row rendered
against what it rendered it from, so it never opens on a row that hid
nothing. (#446)
- **The Info tab's rows do what they show** — the Session table used to render
every fact the same inert way, with two unlabelled actions in a strip four
rows below the path they acted on. A `changes` row is now the sidebar's green
and red `+N −M` and opens the same diff overlay; an agent row wears the
status dot its tab does; a port row hands over its address instead of leaving
it to be retyped; and what a row can do appears at the end of it on hover, in
the strip the Source Control rows already use. A port is only offered as
`localhost` when localhost actually reaches it — a server bound to
`172.17.0.1` is named by the address it really holds. (#531)
- **Closing a tab asks before it ends work that is still running** — ⌘W was the
one action that permanently ended a shell in an app whose headline claim is
that shells outlive it, and a mid-turn coding agent, a forty-minute build and
an idle prompt all died identically and silently. Reopen Closed Tab was never
an undo: it restores the layout with a *fresh* shell. Closing now asks once,
naming what it would end, with Keep as the default button; an idle prompt
still closes without a word. A pane counts as busy only when the shell is
actually reporting, so a terminal without shell integration is not held
hostage by a question it cannot answer. Close Other Tabs and Tabs to the
Right skip anything that would raise a question rather than stacking one
dialog per tab.
- **The opencode plugin reports its session id**, so a restarted pane can
resume the same session with `opencode --session <id>`. It also maps
`session.status` busy/idle for versions that no longer emit `session.idle`. A
task-tool subagent runs in a child session whose events are structurally
identical to the pane's own, so the bridge remembers child ids and lets their
events pass without touching the pane's session — otherwise the pane would
report and resume the subagent's session, and a subagent going idle would
call the pane done. (#481)
- **A documentation site** — the docs are now a Mintlify site rather than a
handful of Markdown files in the repository. (#478)
- **Drop files into the Files panel to copy them in** — the panel has always
been a drag *source*; it now takes a drop as well. Files dragged in from the
desktop land in the folder under the cursor: a folder row takes them itself,
a file row stands in for the folder holding it, and the space below the tree
belongs to the top of it. The landing is highlighted while the drag is in
flight. Folders come in whole, the executable bit survives the copy, and a
name already taken is asked about rather than silently replaced — a "no"
leaves every file in the drop untouched, and a "yes" copies beside what is
there and swaps the two only once the copy is whole, so a copy that fails
partway leaves the original where it was. Two dropped items of the same
name are one name and one file: the first keeps it and the second is
refused, rather than landing on top of it with both reported as copied. It
works over a remote workspace too, reading here and writing there, up to
the size one control frame can carry; past that the panel says to use
SFTP.
- **Rearrange splits by dragging a pane** — hovering a pane now floats a small
grip along its top edge; dragging it moves that pane elsewhere in the tab.
A drop on a pane's **side** goes in beside it: facing a neighbour in the same
row or column it joins that row and takes an equal share of it, and only
facing across the layout — where there is no row to join — does it split that
pane in half. A drop on its **middle** trades the two panes' places, and a
drop in the band past a pane's **outer side** — the one facing the window
rather than another pane — puts it beside everything else as a full-width or
full-height band, sized to an even share of what that side already holds — so
a pane in the middle of a 2×2 becomes a full-height *third* column in a
single drag rather than taking half the window. The landing is highlighted
while the drag is in flight, and is only offered when the drop would actually
change the layout. A pane dropped beside another is now reconciled with the
machine tree as one `PaneMove` instead of a close-and-rebuild; a drop that
lands beside a whole group of panes rather than beside a single one still
takes the rebuild, which is all `PaneMove` can name.
- **Native Windows backdrop materials** — Settings → Appearance now offers a
**Background material** picker on Windows (**Auto / Blur / Mica / Mica Alt /
Acrylic / Off**) that maps onto the OS backdrop APIs: Mica and Mica Alt via
`DWMWA_SYSTEMBACKDROP_TYPE`, Acrylic via the native
`DWMSBT_TRANSIENTWINDOW` material on Windows 11 22H2+ (classic acrylic
before that), and Blur via `ACCENT_ENABLE_ACRYLICBLURBEHIND`. The dropdown
only lists the presets the current Windows build supports, the file
sidebar and right detail panel follow the window opacity so the material
shows through the whole workspace, and the settings panel stays opaque.
macOS and Linux keep the existing blur toggle.
- **Panes come back showing what was on them after the background service dies
unexpectedly** — a crash, a `kill -9` or a reboot takes the shells with it
either way, but the screens no longer go with them. A capped tail of each
pane's output is kept at `<config>/scrollback/*.bin` (0600 on unix, behind
the config directory's ACL on Windows; 256 KiB per pane, written at most
every 30s and only for panes whose output moved), and a pane that reopens on
a dead predecessor's id is handed it. A planned restart already carried the
live ptys across untouched; this covers the deaths nothing gets to prepare
for. There is no switch: the moment anyone learns they wanted this is the
moment a service has already died, so it is on for everyone. The bytes are
dropped as soon as nothing can ask for them — closing a pane deletes its
file at once, a restore consumes it, and a periodic pass collects the rest.
A pane's shell is recorded alongside, so a git bash pane no longer comes
back as PowerShell.
- **`tty7 wait` can wait for a command, not just an agent** — a new `free`
state ends the wait when the pane's foreground command has exited and the
pane is back to its bare shell, which is what a `cargo test` running in a
pane has instead of an agent status. With `--changed` it means "something ran
and then finished", the shape you want on the line after a `send`. It costs a
second request per poll and so is only checked when you name it — and only
when none of the agent states you named answered first, so `waiting,done,free`
on a pane of unknown kind cannot lose you a `waiting`.
- **`tty7 send --key` presses keys instead of typing characters** — `C-c` to
stop a runaway build, `escape` to close a TUI, `up`/`down`/`enter` to answer
a permission prompt that takes no text. Repeatable for a sequence, composable
with `TEXT`, and delivered as separate events 200 ms apart so a raw-mode TUI
reads a sequence as a sequence rather than as a paste. An unknown key name is
a usage error raised before anything is written.
- **`tty7 pane close` takes several panes, and `--orphans` clears the lot** —
`pane ls --all` has been able to *show* the panes an interrupted `run` leaves
behind; there was no way to act on that except by reading ids off the table
one at a time. A pane that cannot be closed no longer abandons the rest of
the batch.
- **`tty7 doctor` reports where the agent status hooks stand** — it has claimed
to check hooks in its own `--help` for a while without doing so. Missing or
outdated hooks are why an agent can look frozen in `tty7 agents` and why
`tty7 wait` on it only ever times out, so the check belongs in the verb
people run when something is not working.
- **SSH probes the `~/.ssh` default identity keys** — a connection with no
identity file of its own used to offer the server nothing unless an agent
was running, which on Windows is the common case (the OpenSSH
Authentication Agent service is off by default), and then reported "no
public key was accepted" when no key had ever been sent. `id_ed25519`,
`id_ecdsa` and `id_rsa` now stand in for the identity list when the
connection names no key of its own, the way `IdentityFile`'s default works
in ssh_config — naming a key replaces them rather than adding to them. The
agent is asked before either, because every public key offered spends one
of the server's `MaxAuthTries` whether or not it is wanted, and the key the
user loaded into the agent is the one most likely to work. A discovered key
that is
encrypted is used only when its passphrase is already in the OS keychain:
russh has no offer-without-signing probe, so asking would spend a prompt on
a key the server may not even want. A key named in the profile still asks,
as before. The failure text now separates the two situations the old line
papered over — keys the server rejected are named, and a round that offered
nothing says where it looked. (#484)
- **A WSL pane gets its shell integration whatever the distro's shell is** —
the bootstrap that runs inside the distro dispatches on `$SHELL`, and until
now only bash had an arm of its own. A distro whose login shell is fish got
one in #422; a zsh distro fell through to the catch-all and opened as a
plain login shell, with no prompt marks and no working directory reported —
so the inline editor never armed, and neither did anything else downstream
of OSC 133. zsh now gets the same redirectors the native path writes,
carried in over `WSLENV` and pointed at with `ZDOTDIR`. The bootstrap checks
they are actually readable from inside the distro before trusting them: they
live on the Windows side and arrive over `/mnt`, which a distro with
automount off cannot see, and a `ZDOTDIR` aimed at nothing would start zsh
with none of the user's own startup files at all. (#135)
- **Put your own entries in the new-tab menu** — `custom_shells` in
`config.json` takes a list of `{label, program, args}`, and each one becomes a
row in the menu behind the sidebar's **+**, after the shells tty7 detected. A
distro, a container, a REPL, the same shell against a different profile: they
are launched exactly as written, since tty7 chose none of the command and has
no defaults to add to it — including its shell integration, so a custom entry
opens without prompt marks or working-directory tracking even where the
detected row beside it has both. `shell` still names the one command that
stands in for the platform default; these are the rest. An entry with no
`program` is
skipped, one with no `label` is named after what it runs, and one that
borrows a name already in the menu is marked `(Custom)` so the row telling
you what a plain new tab opens with stays the only one wearing it. (#443)
### Changed
- **A Windows install for all users now updates itself** — previously an
in-place update was refused there (replacing files under `C:\Program Files`
takes administrator rights, and a silent installer launched unelevated would
either install a second copy per-user or raise a bare UAC prompt from a
temporary directory). The update now runs as one announced UAC prompt that
covers both privileged stages, and the app itself never runs elevated: a
helper running as the signed-in user waits the install out and relaunches
tty7 with the user's own token. The update dialog says the prompt is coming
before the app quits, and "Install on Next Launch" is not offered — nobody
would be there to answer. Declining the prompt is not an error: the staged
package simply waits in Settings. Everything the elevated half needs crosses
as command-line arguments (an elevated child does not inherit the
environment), the package's checksum travels from the release server in the
GUI's memory rather than from a file the download could have rewritten, and
the privileged stages always run the *installed* updater — a medium-integrity
process cannot swap the binary that gets elevated. The staged copy lives in
an administrator-only `%ProgramData%` directory while the chain runs (#504).
Installations updated by an updater that predates the chain still fall back
to the manual download page, so the first release carrying this updates the
way it always did; the one after it updates itself.
- **Every confirmation answers the way the platform taught you it would** — the
action button is on the right and Cancel on the left, through one shared
helper. Answer 0 is drawn rightmost and takes Return, so the old
`&[Cancel, Delete]` put Delete exactly where a decade of macOS trains people
to click Cancel. Escape also cancels now, in all nineteen of them: gpui only
binds it on a button declared as the cancel, and every call site had been
passing plain strings.
- **A search match is washed in the theme's accent, at a strength the theme can
afford** — the wash was drawn from the terminal palette's selection colour at
a fixed 1.45:1 against the background, which is less than a hairline is worth
spread over a whole cell, on a grid that is already grey on grey. The tint is
now the accent, the one colour the terminal surface has nothing else in, and
its strength is derived per theme from that palette's own text-contrast
budget rather than from a constant that has to be safe for the worst
palette. The current match drops its caret-coloured outline, which existed
only because the fill could not say "this one" on its own. (#470)
- **The block cursor is drawn as reverse video** rather than as a 55% tint that
halved whatever contrast the caret had. On a wide character it inverts the
half holding the glyph.
- **An inactive pane dims by blending its terminal colours toward the window
background** instead of being faded as a whole, and the fade is now worn by
the terminal rather than by the pane — which is what keeps the grab grip
legible on exactly the panes `dim_inactive_panes` fades, the ones being
reached for. (#464)
- **The pane grip is three dots, the way Ghostty draws its own** — the bar a
pane was picked up by grew and recoloured under the pointer and appeared
whenever the pointer was anywhere in the pane at all: a mark on top of the
terminal wherever the mouse rested, and a target that moved while being
reached for. It is now three dots with 80×12 of reach around them and nothing
between the two states but ink, asked for by the pane's top fifth rather than
by the whole pane. The target is there for as long as the pane can be moved
and only the dots come and go, so a pointer going straight for the top of a
pane can press the grip on the frame it arrives; a 150 ms fade makes them
read as arriving rather than blinking. (#463)
- **A WSL distro list comes from the registry rather than the WSL service** —
`wsl -l -q` has to reach the service, and reaching the service is the part
that can be slow, behind a hardcoded 3 s timeout that made the listing
all-or-nothing. On a machine where one round trip takes 3.3 s the call timed
out every time and no distro was ever offered in the shell menu: not slow,
absent. `Lxss` is where `wsl.exe` registers them, nothing is listening on it,
and it cannot hang; `wsl -l -q` stays as the fallback for when the key will
not open at all. Distros are filtered by `State`, so a cancelled install or a
failed `--import` no longer arrives in the menu and opens a pane that dies of
a registration error.
- **Opening a WSL tab costs one probe instead of two** — every pane ran the
readiness probe from the client and then the daemon ran the identical probe
before opening the link: two rounds of five serial `wsl.exe` calls to learn
one fact, and the client's copy threw its answer away and kept only the
error. A distro also says once where its server is and is not asked again,
rather than re-proving `uname`, `$HOME`, a stat and a liveness check on every
pane. Measured on a distro already running and connected: 800 ms to open a
tab, down to 440 ms — and on the machine in #454, where a round trip takes
3.3 s, the duplicate alone was costing 15 s a tab.
- **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a
single `{"closed": id}`, because the verb takes more than one pane. A batch
that could not close everything exits 1 with `{"closed": […], "failed": […]}`
and the complaint on stderr, so a retry knows what is left.
### Fixed
- **Ctrl+C works in a pane on Windows again** — the daemon was created with
`CREATE_NEW_PROCESS_GROUP`, which disables Ctrl+C for the whole new group,
and Windows hands that state down to every descendant. Every ConPTY shell a
pane spawned inherited it and so did everything those shells ran: the pane
wrote 0x03 and conhost turned it into a keypress, but the `CTRL_C_EVENT`
never came, so `go run` and `npm install` carried on regardless. Git Bash
looked fine only because MSYS synthesises SIGINT from the byte itself.
`DETACHED_PROCESS` already left the daemon without a console for a control
event to arrive on, so the group flag bought nothing to begin with; both
daemon spawn paths and the headless CLI server now share one constant
without it, and a pane clears any inherited ignore before it opens the pty,
so a tty7 launched from a shell that already had the bit set is covered too.
(#459, #451, #314)
- **Two tty7s on different config directories no longer erase each other's
workspaces** — the machine tree resolved from `$HOME` while everything else
an instance owns resolved from the config directory, so `--config-dir` moved
every part of an instance except the one that says which workspaces exist.
Two daemons, each holding its own lock and each certain it was the only
server on the machine, co-owned one `machine.json`; the tree is written
whole, so the second to flush replaced the first's workspaces with its own,
and the next daemon to start read the survivor's tree as the machine's. An
empty tree is indistinguishable from a machine that really has nothing on
it, so the GUI did what an empty tree means and forgot those workspaces for
good. A lock and the thing it protects have to be keyed alike. The daemon
adopts a legacy file on startup so the move itself loses nothing, and only
the instance running out of the config directory the machine resolves to on
its own is entitled to that adoption — otherwise a default install beside a
`--config-dir` one would rename the machine's tree into the wrong place.
(#462)
- **A remote window that lost its first tree pull no longer sits empty until
you restart** — a window opening onto a remote workspace is blank until the
machine's tree is pulled and its tabs rebuilt, and a failed pull recorded a
debt on the promise that the next sync would settle it. Nothing settled it:
the hook that repays the debt ran for the local host and nowhere else, so on
a remote machine only a full reconnect, an edit in the window, or restarting
the app ever cleared it — and an empty window has nothing in it to edit. The
window sat on the home page with every tab and every shell still alive on the
machine. Both routine ways to fail a pull with a healthy link are covered
now: a tree read that overruns its ten seconds, and a workspace create that
loses a race with the priming pass running the same create from the other
side of the same window opening — that one is no longer treated as a failure
at all, the tree is simply read again. The retry is backed off, the backoff
ends with the run of failures rather than with the next success, and a window
left open on a machine that is really gone stops logging at warn once the
backoff settles at its cap. (#472)
- **The macOS DMG builds again** — every release and nightly macOS job had been
dying in packaging with `hdiutil: create failed - No space left on device`,
after the build, the signing and the notarization had all succeeded. The
runner's disk was never full: the path in the error is the image being
created, so it was the volume that ran out. `hdiutil create -srcfolder` sizes
the image from the bytes it is about to copy and does not cover what the
filesystem spends carrying them, so a bundle that fits by measurement still
runs the volume dry partway through. The room is asked for explicitly now —
twice the content plus 64 MiB — and since the image is compressed on the way
out the slack is nearly free: 127 MiB of empty volume costs about 672 KiB in
the published DMG. This was a threshold rather than a cliff, which is why it
began without anyone touching packaging. (#477, #476)
- **The CLI and the GUI agree on what exists** — five places where a workspace,
a tab or an attachment was real on one side of the socket and invisible on
the other, all rooted in the GUI keeping its own list of which workspaces
exist and consulting the machine tree only for the ones already in it, so
anything another client created was unreachable by construction. The switcher
now lists workspaces the machine holds that this client has never opened, and
opening one keeps its id instead of claiming a fresh one; opening a workspace
hydrates from the machine's tabs rather than saving an empty session over
them; a workspace removed with `ws rm` stays removed instead of being written
back under the same id and haunting the switcher until a restart; `tty7 ls`
can name the host holding a workspace; and `tab ls` / `ws tree` fall back
through name, agent, cwd leaf and process name rather than showing a blank.
(#423)
- **A pane the CLI created can be attached to** — a pane's owner names the
workspace allowed to attach to it, and the CLI wrote a literal `tty7-cli`
there for every pane it made. A window opening on a CLI-built workspace found
none of them attachable: it spawned a fresh shell per tab, orphaned the live
ones, and — because the tree still carried each pane's agent session — greeted
the user with a failing `claude --resume <id>` in every one of them. Both
spawn paths pass the workspace id now, and restore treats an unparseable
owner as no claim at all, so panes stamped by an older CLI attach instead of
stranding. `pane ls --all`'s OWNER column shows a dash when it agrees with
WS, so what is left in it is exactly what is worth reading. (#425)
- **A WSL pane starts when the distro's default shell is fish** — `wsl.exe --`
hands the command line to that default shell, which parsed the POSIX
bootstrap before `sh` could receive it, so the pane never started at all.
Every invocation carrying an argv goes out under `--exec` now. Those panes
then came up with no shell integration, because the bootstrap only had a bash
arm: no OSC 133 and no OSC 7, so no prompt marks, no exit status, no cwd, and
`tty7 wait` and busy/idle status dead in the pane. There is a fish arm now,
POSIX-quoted since `sh` parses the script rather than the user's own shell.
(#422)
- **A remembered WSL server path repairs itself** — the note saying where a
distro's server lives had no working way to be wrong: its only repair fired
when spawning `wsl.exe` failed, and `wsl.exe` starts perfectly happily with a
server path that no longer exists inside the distro. The exec failure arrives
later as an EOF on the bridge, so a distro that was reinstalled or had its
bin directory cleaned out failed every WSL tab from then on with no way out
but restarting tty7. A bridge that closed without ever sending a byte never
ran, and after one of those the next pane proves the distro again. The note
is also read under the install lock now, so a window restoring panes can no
longer spawn the binary that a server update is in the middle of replacing.
- **A half-read registry is no longer passed off as the distro list** — the
walk ended on any non-zero return and reported what it had, but only one of
those returns means "that was all of them"; the rest mean it stopped early,
and a failure at the very first index came back as an authoritative "there
are no distros". The sweep behind the shell menu keeps its last good list
only when the probe declines to answer, so that empty answer erased the
user's distros for the length of the TTL, with no error anywhere. The walk
says nothing now unless it reached the end.
- **The Source Control panel is on the same font scale as the rest of the
window** — the panel was cut from a branch that copied the right panel's size
ramp hours before the interface font scale landed on main and moved that ramp
onto rems. The two sides touched different files, so the merge had nothing to
conflict over and the panel shipped a step small and deaf to `ui_font_size`
entirely — its primary text at the size its neighbours use for secondary
text, and a file row mixing a rem-sized status letter with a pixel-sized
path. Every size a reader reads is a rem now; pixels stay on what type sits
inside, and the row pitches and section heights grow with the type they hold.
The commit body's fold also folds: `line_clamp` limits the wrapped lines
within one logical line, and a commit message is the one string this panel
draws that is always hard-wrapped, so a clamp of four over a thirty-line body
laid out all thirty and Show more was a label with nothing behind it. It
clips a height now. (#557)
- **A search highlight stays on its text while the pane is printing** — a match
point is a line of the grid it was scanned from, so every line that scrolled
off the top left each highlight washing the row its text had just left. The
stored points now slide with the text on every wakeup. The rescan behind them
was purely debounced, so a pane that never pauses — an agent streaming a
reply — never got one at all: the count froze and the highlights sat on text
that had since been rewritten in place. Output can now only push the deadline
out so far. (#559)
- **A machine's name gets its own line in the switcher** — a group header drew
the machine name and its `user@host:port` on one row, and the endpoint was a
fixed-width item that claimed its space first, so a machine whose name ran
long read as a stub: `y...`, `GAEM...`. Both the header and the "other
machines" rows use the two-line shape the workspace rows under them already
use, with the name backed by the full row width and the endpoint and link
status underneath. Rows with neither stay single-line. (#560)
- **The stray guide rail under a remote machine is gone** — expanding one in
the switcher drew a 1px vertical line left of the workspace avatars that
local groups never drew, so it read as a stray mark rather than an indent
guide, and it stopped short of the last row's midpoint leaving a dangling
stub. Remote rows indent exactly like the SSH host rows below them now.
(#536)
- **The workspace discs in the switcher are legible** — the monogram disc was
drawn at 0.55 opacity on every row except the current workspace, and the
initial inside it is already the foreground at 0.65, so the letter landed at
about 0.36 and went illegible on exactly the rows the panel exists to let you
choose between. The liveness dot is painted on the wrapper rather than inside
the disc, so it never dimmed with it — a washed-out grey blob with a
full-strength green dot stuck to its corner, which is what made the column
look dirty rather than quiet. The dimming is gone; the current workspace was
already saying so three louder ways. (#483)
- **Overlay scrollbars fade out again** — macOS reports that scrollbars should
not auto-hide for anyone with a mouse plugged in, and that was turned into
"always show" for every list in the app. The preference is about legacy
scrollbars, which take a gutter out of the layout; tty7's are overlay bars
painted on top of the content, so "always" parked an opaque bar over the
switcher's tab column for as long as the panel stayed open, with nothing to
fade it. (#471)
- **A tab label is cut on grapheme clusters, not on chars** — the sidebar's
elision measures against real glyph widths but sliced by `char`, so it could
satisfy every width check and still hand back a torn cluster: a
zero-width joiner with nothing in front of it, a variation selector landing
on the ellipsis, half a flag rendering as a bare letter. Scanning budgets
from 30px to 200px over emoji fixtures, 48 widths produced output no font can
render as intended. A tab title carrying an emoji is not exotic — plenty of
TUIs and coding agents put one there. Widths are unchanged, since clusters
are measured the same way chars were. (#450)
- **A multiline history entry draws on one menu row** — a command composed in
the inline editor keeps its newlines when it goes into the in-session
history, and gpui breaks text on `\n` whatever the white-space setting says,
so one such entry painted a dozen lines inside a one-line row and covered the
whole reverse-search menu. Line breaks are folded to a visible `↵` when
drawing — one char for one char, so the fuzzy matcher's highlight positions
still line up — and the stored entry is untouched, so recalling and running
it are unchanged. (#436)
- **One Restart server button on the Settings page** — the in-place-update
notice carried its own while the Server section right below it carried an
identical one. The notice moved into that section, so the single button that
ends every running pane is the only one on the page. (#452)
- **Several machines with outdated servers ask one question at a time** —
reconnecting to them raised one native modal per machine in a single pass,
stacked on each other, each about a machine the one in front of it did not
name. The queue is walked one question at a time now, and if the window goes
away with a question open the rest go back on the queue instead of being
swallowed.
- **Gestures that did nothing at all** — a pass over the whole app, most of it
found by driving the UI rather than by reading the code:
- **Escape in a confirmation dialog** cancels, in all nineteen of them.
- **⌘P then Return** now runs the first row. The list started with nothing
selected and only picked a row when a *query* changed, so nothing showed
what Return was aimed at.
- **The palette's `→ edit` badge** is `⌘E`. Neither advertised gesture could
fire — `→` is eaten by the query field and `⌘↵` by Enter Full Screen — so a
saved SSH profile could not be opened for editing from the palette at all.
- **⌘G past the last match** wrapped to match 1 and left it behind the
floating search bar, reading "1/3" with nothing visible. The bar's rows
count as off screen now.
- **`ls | gre` + Tab** completes `grep` rather than filenames. The
command-position test was "everything before the cursor is whitespace",
true only at the very start of the line; over SSH it also spent a round
trip listing the remote cwd to do it. Same for `a && b` and `a; b`.
- **Typing or pasting while scrolled back** snaps the view to the prompt, so
your own keystrokes are not landing off screen.
- **Typing into a filled text box** — rename, prompts — replaces the text
instead of prepending to it. Every one of them opens with its contents
selected.
- **Double-clicking a file over SFTP** opens it; only folders used to
respond.
- **Tab inside the palette** stays in the palette, and in the switcher
crosses to the tab column, instead of escaping to the next focusable
button.
- **Reveal in Finder on a remote workspace** is hidden rather than handing
the local file manager a path on another machine. Copy Path stays.
- **Things that failed silently now say so** — theme writes, theme
duplication, opening the themes folder, a server restart, a session restore
that came back short, an unparseable theme file, an SFTP overwrite, a search
count that hit its ceiling, a file-tree search that hit its ceiling, a delete
that did not go through, SSH auth with nothing to try, and an unknown action
in a keymap each either did nothing visible or blamed something else. All of
them now report what happened and, where there is one, what to do. The
sidebar's diff count no longer disagrees with the diff it opens, a
filtered-out SFTP directory is no longer called empty, and the theme picker
says when its filter matched nothing.
- **A directory with no children says which kind of nothing it is** — the
listing error was discarded, so a folder the OS refused rendered
byte-identically to one that really is empty and to one still being read. An
expanded directory now draws Reading…, Empty, Only hidden files, or Could not
be read; arrow-key navigation skips those placeholders because they are not
files. The tree column and the tab sidebar get the same treatment when a
filter drops everything.
- **Layout and hit targets** — the settings pages, the diff overlay, the
Markdown preview, the SFTP transfers list and the switcher's columns all
scrolled with no scrollbar and now have the one the rest of the app has. The
split divider takes an 8px grab, the same as the panel edges. Icon-only
buttons across the chrome get a 24×24 target without changing how they look,
and their tooltips print the chord. The active tab and the New Tab button
stay on the tab strip instead of scrolling out of reach. The switcher card
fits inside the window it floats over, and the window itself has a minimum
size. Below about 1000px the SSH host form stacks each control onto its own
line — rows measure the width they will actually get — rather than squeezing
the label column toward a letter per line and pushing Connect and the 2FA
option off screen. Nothing moves above the breakpoint.
- **Contrast, colour and theming** — button labels, panel headings, tooltips,
menus, the command line and the splitters are drawn in the active preset's
own colours instead of ignoring it. Sidebar text, carets, hairlines and the
semantic inks that bypassed the contrast machinery are floored, and a
hairline is worth the same in every theme. Three faded states had two values
each, the app had two transition curves, and the theme card rounded unlike
the panel it sits in: one value, one duration and curve, one radius.
- **An agent's state is not carried by hue alone** — Working, Needs input and
Done were a hue on a nine-pixel dot with no shape, label or tooltip
anywhere, and the pair that decides whether you go and look is amber versus
green, which is the pair red-green colour vision separates worst. Needs input
draws as a ring rather than a filled dot, the tab avatar has a tooltip naming
the agent and its state in all three locales, and a brand disc that lands
within 1.25:1 of the window fill — Codex and Grok both ship a pure black one,
which dissolves on a dark theme and leaves a white glyph floating — gets a
hairline.
- **The palette stops showing recent commands twice** — the zero-query list
pushed a Recent section and then pushed every group containing those same
commands, so the five rows you use most were the five rows shown twice.
Promoting to Recent moves a command now rather than cloning it, and a group
left empty drops its header with it. Ranking also threw frecency away the
moment a character was typed, so the command someone runs every day stopped
floating exactly when they started reaching for it; a bounded bonus rides
along with the match score — enough to separate two comparable matches, not
enough to let a well-worn command outrank a plainly better one.
- **SFTP transfers stop writing over files without a word** — downloading the
same file twice overwrote the first copy and uploading into the folder on
screen overwrote whatever was already there, neither with any warning and
neither recoverable. A download lands as `name (2).ext` the way a browser
does, still in one click; an upload asks once, naming what it would replace.
The upload check reads the listing already on screen rather than making a
round trip to ask.
- **Escape no longer throws away an unsaved SSH connection** — editing a
profile and pressing Escape closed Settings and discarded the form. Whether
the form was dirty was already known — it is what enables Save — but was only
used to grey out a button. Escape, the close button and ⌘, ask now, with Keep
Editing as the default. The nineteen rows of SSH Advanced also carry six
quiet headings — Authentication, Proxies, Algorithms, Connection, Session,
Security — of two to four rows each; no row moved, the existing order already
grouped this way and never said so.
- **Settings search marks what it counted** and scrolls the page to the hit,
including when the hit is a section header. Background images, Compression
and four other settings that were missing from the index are in it, and a
query that matches nothing on the current page no longer greys out a page you
are simply reading.
- **Copy, i18n and discoverability** — every failure opens with "Could not", as
most already did; every popup menu is in the Title Case the rest use; the
background server has one name everywhere; settings labels are sentence case;
and no Markdown backticks are printed at the user. The terminal's right-click
menu was the last surface still hardcoded English in a three-locale app, and
it also used a fourth name for actions the menu bar, palette and Keybindings
already agree on. Chinese quotes names the way Chinese quotes them, Japanese
descriptions end the way the other eighty do, the 16 ANSI colours are named
rather than numbered, and the remote Files search box re-translates on a
language switch. The i18n parity test was a hand-maintained 900-line copy of
the key list and now sweeps a generated one, with a documented allow-list for
the keys that stay English — and the untranslated-string guard actually
guards. Completion specs got git's missing descriptions filled in, cargo's
`--color` described like the other 41, and a rustup flag that described
itself fixed; a candidate says when its description was cut. The Keybindings
page is grouped by command group, SSH is reachable from the menu bar, "Enter
Full Screen" no longer appears twice, ⌘W names the command it is about to
end, and the View menu teaches the right key for Zoom Pane.
- **Switching workspaces no longer wipes the tab titles of the one you left** —
in the switcher, the workspace the window was showing had properly named tabs
while every other one read "Claude Code", over and over, for as many tabs as
it had. The two columns come from two sources: a window names its own tabs
from its live terminals' OSC titles, and every workspace it does not own it
reads out of the machine tree — which recorded each pane's foreground
*process* name and never the terminal's title, so the naming fell through to
the next best thing it had, the agent. Switching a workspace happens in place
and drops the terminals the window was reading, so the workspace you just
left went from named tabs to a column of identical rows. The daemon now
records the title a pane sets (OSC 0 and 2, capped at 256 characters) in the
tree beside its cwd, and the switcher shows it, put through the same
abbreviation the tab strip uses so a shell's `user@host:~/dir` reads `…/dir`
in both places. In a split, the pane running an agent names the tab rather
than whichever shell happens to be first. `tty7 tab ls` and workspaces listed
on a remote machine were reading the same missing field, and are named the
same way now.
- **A WSL distro that cannot reach `/mnt` keeps its own `.bashrc`** — the
bootstrap handed bash a `--rcfile` living on the Windows side without
checking the distro could read it. bash ignores an unreadable `--rcfile`
without a word, and tty7 starts it non-login precisely so that `--rcfile` is
honoured at all — so a distro with automount off, or a root moved by
`wsl.conf`, opened a shell that had read no startup file the user wrote: not
tty7's, and not their `.bashrc` or `.bash_profile` either. The arm now checks
first and falls back to a plain login shell, which reads the same startup
files a pane without shell integration reads and only gives up the
integration.
- **An idle window stops re-reading the repository on Linux** — with a
repository open, tty7 ran `git status -uall` about two and a half times a
second, forever, with nothing on screen changing and nobody touching the
machine. The filesystem watch was feeding itself: Linux reports opening a
file as a watch event, every read of `.git` is an open, and reading `.git`
is exactly how the watch's own events were answered — so each answer
scheduled the next question. Reads are no longer mistaken for changes;
a write finishing still is. macOS and Windows were never affected, which is
why this survived so long.
- **An alias put back in an `Include`d ssh config file is found again** — the
check behind a parked remote workspace watched `~/.ssh/config` for changes
and nothing else, but `Include` is common and editing an included file
leaves the including file's timestamp exactly where it was. So re-adding a
`Host` block where it used to live changed nothing: the workspaces on that
alias stayed parked, with no retry and no error, saying a new profile would
find them again — which is what the user had just done. Every file the parse
reads is watched now, and the root config even when it could not be read, so
one appearing later is noticed too.
- **A rejected stored credential asks again instead of failing forever** — a key
passphrase saved with "remember" was written to the keychain before the daemon
had tried it, and a wrong one then ended every later connection at "could not
decrypt identity file" with no prompt at all: the key was unusable and nothing
in the app could clear the entry. Keyboard-interactive had the matching hole,
replaying a stale saved password on every reconnect, because OpenSSH ends a
rejected request with a plain failure rather than another question. The daemon
now says which secret was refused, both paths ask again, and an answer given
without "remember" forgets the stale entry — the self-heal the password path
has always had. Deleting a host profile also stops stranding its key
passphrases in the keychain, and a prompt raised for a remote workspace now
carries the real user, host and port instead of filing everything under port
22.
- **A host key of a new algorithm is no longer reported as a compromise** — a
server that grew an ed25519 key beside the ssh-rsa one it had always had was
met with the full man-in-the-middle sheet: fingerprint diff, type `yes` to
override. Worse, negotiation always led with ed25519 rather than with what was
on file, so a host known only by an ssh-rsa key raised that alarm on every
single connection. The algorithms already in `known_hosts` are now offered
first, the way OpenSSH's own ordering does it, so the question usually never
comes up; when it does it is the ordinary "a key you have not seen" confirm,
naming the algorithm the host is already known by. A key that contradicts one
on file still raises the alarm, and overriding it now removes the superseded
line instead of leaving the rejected key trusted for good.
- **The "Override" button on a changed host key overrides** — it submitted the
same refusal the Abort button sends whenever the field did not read exactly
`yes`, so clicking it, or typing a stray character first, closed the sheet and
quietly abandoned the connection. It is now disabled until the confirmation is
typed, says so once there is something in the field, and Enter no longer
abandons the connection either.
- **One account of whether a machine is connected** — the window's memory of a
manual attempt used to outrank the supervisor that actually holds the links,
in every place the two were consulted. Taking a workspace back from another
client flipped the status to Attached and re-enabled input without ever
sending the attach, so keystrokes went into a socket the far end had given
away; a link brought up from the switcher never attached anything at all; a
failed connect owned the status strip of every remote window, including
windows on other machines, until it was dismissed by hand; and a machine being
retried in the background was drawn as one nobody had ever connected to, its
workspaces folded out of the switcher, with the reason for the last failure on
screen for a quarter of a second before being overwritten. The supervisor is
now the only account: the reclaim is really sent and undone if it is refused,
a stale failure is retired the moment the link comes up, and a retry says
which attempt it is on and what went wrong last time.
- **A failed SFTP poll no longer reads as an empty transfer list** — a poll that
could not reach the daemon returned no jobs, which is the same answer as
having none: the transfer tray disappeared and every upload the panel was
waiting on was counted as landed. Over a link that is down that was the
permanent answer, not a blink. A failed poll now keeps the transfers it cannot
see, settles nothing, and says so in the tray.
- **A port forward whose loop has exited stops reporting Listening** — the
status was written once when the forward was set up and never again, so a
forward whose accept loop had already gone reported itself as healthy for as
long as the pane stayed open — which is indefinitely, because a pane is
deliberately not closed when its SSH connection dies. The loop now records why
it left, and the panel shows it.
- **A file-tree search that failed says so** — an unreachable host, or a search
the host refused, drew "Nothing matches" — indistinguishable from a search
that ran and found nothing, and every bit as convincing. It now says the
search failed. Creating or renaming a file through the tree also stops
reporting a bare `Permission denied (os error 13)` and names the file and the
operation, the way deleting one already did.
- **A port-forward edit cannot lose the rule it replaces** — saving an edit
removed the old rule before adding the new one, so an edit onto a port already
in use ended with the working forward gone and nothing in its place. The old
rule is now put back when the new one cannot start, and the form stays open
with the reason. Add and Save also stop doing nothing in silence: an
unparseable bind port, a missing target host or a target port of 0 used to be
a button that did not respond, and now disables itself and says which field is
wrong. A forward request that never reaches the session no longer empties the
panel either.
- **A half-filled SSH profile is refused rather than saved** — the form saved
whatever was in it. An empty host became a blank, unidentifiable row in the
host list and reached the socket layer as a DNS error about a name nobody had
typed, because Connect had no gate at all. A mistyped jump host was silently
dropped and saved as a direct connection. `proxy.example.com` with no port,
and `proxy.example.com:88O` with a bad one, both saved a proxy on port 0 and
failed much later, somewhere else. Each complaint now prints under its own
field and disables Save and Connect: the host is required (a name is not, and
a blank port still means 22), an unknown or self-referential jump host is
named out loud, and a proxy address with no port takes the scheme's default —
1080 or 8080 — rather than none at all. A proxy already saved on port 0 is
read as no proxy.
- **An ssh config import says what it did** — the button was silent three ways
over: a missing or unreadable file did nothing, a file naming no importable
hosts did nothing, and a clean import of six hosts also did nothing visible.
It now reports each of those, with counts of the hosts added, updated and
already current, and names the options tty7 has no field for — `IdentityAgent`,
`CertificateFile`, `IdentitiesOnly` and the rest — grouped with the hosts that
set them, so a host that behaves differently under tty7 than under `ssh` has
something to point at.
- **"Forget Password" asks first, and says who else it signs out** — one click
deleted the keychain entry, irreversibly and without a word. The entry belongs
to the endpoint rather than the profile, so forgetting from one host also
signed out every other profile reaching the same `user@host:port` — a direct
connection and one through a jump host, say — and that only turned up as an
unexpected password prompt on a host nobody had touched. It now confirms, and
when the endpoint is shared the dialog says how many others go with it.
- **The Files tree follows an agent into a git worktree** — the pane's cwd row,
the tab sidebar and the source control panel all track where a coding agent
is actually working, because an agent reports its own directory over the hook
stream. The file tree was the one panel still reading the foreground
process's cwd, and an agent that moves into a worktree never `chdir`s — so
`claude` entering `.claude/worktrees/feature` left the tree rooted in the
main checkout, disagreeing with the path printed directly above it.
- **`tty7 wait` no longer calls a busy shell `idle`** — a pane with nothing
reporting agent status was reported as `idle`, so `tty7 wait %3 --until idle`
returned success immediately, `matched: true`, about a pane that was midway
through a build. Those panes now report `no-agent`, which is both true and
the signal to use `--until free` instead; a wait that times out there says so.
- **Deleting an SSH profile no longer strands its remote workspaces** — the
switcher kept every entry that had connected through the profile, labelled
with a bare internal id, retrying a route that could never work again. The
confirmation now names the profile's endpoint and counts the entries going
with it, and deleting forgets those bookmarks along with the profile and its
keychain credentials — *forgets*, not deletes: the sessions on the remote
machine are untouched, and connecting to it again under a new profile brings
its workspaces back from the machine's own list. An entry holding a live or
in-flight connection is left alone, and parks once the link drops: it stops
retrying, keeps the name of the route that made it rather than the id, and
its switcher row offers *Remove entry* next to a note that a new profile
finds the session again.
- **Opening a folder from Explorer no longer costs you your layout** — "Open in
tty7", "Open tty7 here", and `tty7 <PATH>` restore the last window's tabs and
splits before opening the folder as one more tab in it. A launch naming a
directory used to skip the restore outright whenever no window was already
up — whatever "Restore last layout" said — so the folder arrived as a lone
blank terminal and the previous tabs were left behind, still running on the
server but reachable only through the workspace switcher. With a window
already up the same menu entry had always just added a tab, which is the
behavior both shapes of launch now share. A path still declines to follow the
layout onto a remote machine and starts a fresh local workspace there, since
the directory it names is a path on this computer.
- **An SFTP upload no longer sits in the browser under its temporary name** —
an upload is written as `<name>.tty7-upload-<hex>` and renamed into place at
the end, and the browser listed the directory the moment the transfer
started, catching exactly that name. Nothing listed again afterwards, so a
finished upload read as a file with a hash glued to its name until the
directory was navigated by hand. The premature listing is gone and the
directory is listed once the upload stops running — finished, failed or
cancelled.
- **Drag cursors on Windows** — the grip on a pane's top edge, and a sidebar
group being dragged, now change the pointer on Windows too. Win32 ships no
open- or closed-hand cursor and gpui answers both with the plain arrow, so
the grip read as ordinary background and a drag in flight gave the pointer
nothing to say; both now use the pointing hand there, and hovering the grip
no longer hands the cursor back to the arrow the moment the drag it
advertised begins.
- **A failed update install says so instead of asking again** — the GUI hands
the install to the `tty7-updater` helper and quits, so a failure inside the
helper used to reach only `update.log`: the old version relaunched with no
word about it, and because the prompt state had already been cleared, the
next check offered the very same version again — and again. The helper now
records the terminal outcome of every attempt (`update-outcome.json` next
to `update.json`) — before relaunching the previous app, so the relaunched
GUI finds it already on disk — and the GUI folds it into the update state at
startup: Settings shows the failure with the installer's own reason until
dismissed, and the version asks again only after the same three-day
reminder "Later" uses rather than on the next launch, so one failed install
neither nags nor quietly retires the version. The same channel carries the
success case, so a failure an earlier attempt recorded is retired by the
update that did land. As part of this the helper takes the config directory
as a `--config-dir` argument rather than through the environment, which an
elevated child process would not inherit (#540).
## [26.8.2] - 2026-08-09
### Added
- **Update tty7 without leaving the app** — the launch check and
**Settings → About → Check Now** now offer **Update and Relaunch** instead of
sending the user to GitHub Releases. A dedicated `tty7-updater` helper
verifies the release checksum, the bundle version, and — on macOS — the
code-signing requirement before replacing the installation, and restores the
previous copy if the relaunch fails. The update is fetched and verified while
the prompt is up, so installing it is a restart; declining defers it rather
than retiring it.
On macOS the new GUI reuses a running local server when its wire protocol is
compatible, preserving shells; an incompatible server keeps its shells too and
raises the existing explicit keep-or-restart prompt. Windows cannot replace a
running daemon's image, so its install path stops the service and the dialog
says so. Layouts that cannot be updated in place — notably an all-users
`C:\Program Files` install, where running Setup as the signed-in user would
either install a second copy beside the real one or put a bare UAC prompt in
front of a user whose GUI just vanished — keep the release-page fallback.
(by @ayamir in #309, by @ArnoChenFx in #330)
- **Stable and Nightly are separate update channels** — the channel is a
property of the installation rather than something inferred from how version
numbers sort, so a Nightly build follows Nightly instead of being walked back
onto Stable by an update it never asked for. Stable reads `/releases/latest`,
which excludes prereleases; Nightly reads `/releases/tags/nightly`. Neither
feed can hand the other an update, so an installation only changes channel
when the user changes it in Settings.
The nightly release cannot state its version in its tag — `nightly` is
force-moved every night, so `tag_name` is the literal string — so it now
publishes `nightly.json` beside the packages, falling back to parsing asset
filenames for builds that predate the manifest. Prereleases are ordered by
every numeric identifier in the stamp, and the stamp goes to the minute so two
builds in one day are distinguishable; a stable release still outranks every
dated build of its core version, which is how switching back to Stable
graduates rather than downgrades. Switching channel invalidates what the old
feed produced: the staged package, the deferred prompt, and any transfer still
in flight. Skipping a version is retired along with its state and its Settings
row. (#386)
- **A proxy for tty7's own network traffic** — update checks, release downloads
and remote-server installs now resolve an HTTP/SOCKS proxy from, in order, a
new `http_proxy` config field, the platform system proxy (Windows registry,
macOS `SCDynamicStore`), and `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`.
Programs running in a pane are deliberately unaffected — they inherit their
proxy from their own environment, as in any other terminal.
(by @shihuaidexianyu in #367, reported in #365)
- **`tty7 wait` — the primitive an agent team was missing** — block until a
pane's agent needs input, finishes its turn, or dies:
`tty7 wait %3 --until waiting,done --changed --timeout 600`. The daemon
already tracked, per pane, which coding agent runs there and its status
(idle / working / **waiting on the user** / done, plus the agent's native
session id), but the only consumer was the GUI's status dots. This hands
the same view to programs: one agent can start a worker in another pane,
sleep until its peer blocks on a permission prompt, answer it, and read
the result — which a tmux-based agent team has to fake by screen-scraping
`capture-pane` on a timer.
The status the server keeps is a *level*, not an event: `done` stands
until the next turn begins, `waiting` until the agent moves again. So
`--changed` is not optional dressing — it ignores the state the pane was
already in when the wait began, which is what every round after the first
needs. Without it, a wait issued right after a `send` answers with the
*previous* turn's state before the worker has even read the input; the
JSON's `stale` flag says when that happened. Exit codes are made for
scripts: `0` matched, `124` timed out (the `timeout(1)` convention), `1`
with `"status": "exit"` when the worker died before it could get there.
The reply carries the agent's message and native session id, so a wake-up
is directly actionable.
Polling `AgentStates` rather than subscribing to events, on purpose: a
stateless question composes into scripts (`tty7 wait %3 && tty7 capture %3
--plain`), survives a server restart mid-wait, and needs no cursor. The
cost is one aggregate request per tick — the same one `tty7 agents` makes
once. (by @yetone in #248)
- **An agent-facing skill for the CLI** — the repo carries
[`skills/tty7`](skills/tty7/SKILL.md), which teaches a coding agent the
verbs above: work out which pane it is sitting in, split one, send a task
into another, capture what came back, run a command in a real PTY and pass
its exit code through. You install it yourself, with
`npx skills add l0ng-ai/tty7` — tty7 writes nothing into `~/.claude` for
it, and there is no switch in Settings that does.
A skill rather than a global instruction, on purpose: an earlier cut
appended this guidance to `~/.claude/CLAUDE.md`, which taxed every
session's context window and, worse, encouraged *every* agent to go drive
its neighbours. As a skill, only its one-line description rides in context
until something explicitly reaches for it.
- **Smooth scrolling for wheel mice** — a notch now eases into place over a
handful of frames instead of jumping the whole distance at once.
**Settings → Terminal → Scrolling → Smooth scrolling**, on by default.
Sub-line scroll positions have been in place for a while: the view keeps a
fractional remainder and paints the grid shifted by it, so the scrollback
need not land on a line boundary. But the position was a function of the
*event*, not of *time* — a notch arrived and the whole thing was applied at
once. Whether that looked smooth came down entirely to how fine-grained the
platform's deltas were. A macOS trackpad reports pixels, so it did. A wheel
on Windows reports whole lines (gpui multiplies the notch by the system's
scroll-lines setting, three by default), the fraction came out zero every
time, and the view jumped three lines per notch — the sub-line machinery was
present and never engaged. That is the "no smooth scroll on Windows" report.
macOS was no better, just differently broken: it reports a wheel mouse as
*pixels* too — `hasPreciseScrollingDeltas` is set — so the fraction was never
zero, but one detent arrives as a single ~103px event, which at a 21px line
height is a five-line jump applied in one go. Worse than Windows, and
invisible to any check based on the delta's type.
A wheel detent is one discrete pulse; no amount of arithmetic on the delta
recovers a continuous gesture from it. So the fix is to make position a
function of time: a detent adds to a remaining distance, and each frame
consumes a share of what is left. Roughly 120ms to land, exponential, so most
of the travel happens immediately and the tail is invisible — under a pixel
of remainder is snapped rather than approached, since every frame of it costs
a repaint.
Trackpads keep the direct path; putting an animation between the fingers and
the grid would only add lag. What separates the two is **phase**, not delta
type and not delta size — measured on this machine, a trackpad flick moves up
to ~3 lines in one event while an inched wheel moves ~0.6, so size overlaps
badly, but only a device that can gesture ever reports `Started`/`Ended`, and
a wheel is `Moved` forever on every platform. The gesture is then held open
on a 150ms idle timer rather than closed on `Ended`, because lifting the
fingers is not the end of the stream: the momentum tail keeps delivering
`Moved` events *larger* than the gesture itself, and animating those would
smooth what the system is already smoothing.
Two more things stay direct. A jump under a line reads as continuous already
— inching a wheel one detent at a time lands there — and mouse reporting and
alternate-scroll forward whole lines to the application, which cannot be
spread over frames at all.
The distance in flight is relative, not an absolute target, so output
arriving mid-scroll shifts the grid without dragging the animation somewhere
else. Everything that moves the view on its own — jumping to a prompt,
dragging a selection past the edge, clearing the scrollback, the keyboard and
mouse-reporting paths — cancels what is in flight first. (#382)
- **The GUI speaks English, Simplified Chinese and Japanese** — every string in
the interface now goes through a locale table, selected by **Settings →
General → Language**. The menu bar, command palette, switcher, settings,
SFTP panel, file tree, diff overlay, toasts, SSH prompts and confirmations are
all translated, with plural- and select-aware helpers where a count or a
gender-free choice is involved. The `zh` and `ja` key tables are exhaustive,
so adding an `L10nKey` fails the build until it is translated rather than
silently rendering English.
Two things stay in English on purpose. A theme's name is data, not chrome —
it is written into the theme YAML and matched back by suffix, so translating
it forked "Nord" into a Chinese name that then stacked a second suffix on the
next fork and stayed Chinese after switching the GUI back. And the words
Chinese developers read and speak in English anyway — hook, agent, worktree,
diff, fork, shell, pane, server — are left alone; translating them lost more
than it gained. Language names in the picker stay endonyms (English /
简体中文 / 日本語) in every locale.
(by @shihuaidexianyu in #303, by @cwatanab in #372)
- **Oh My Pi (`omp`) is a first-class agent** — the eighteenth CLI tty7
recognizes in a pane, with the full set: brand avatar, status dot, session
resume (`omp --resume <id>`), and **Fork Session** (`omp --fork <id>`).
**Settings → Agents** installs its hooks like the others.
Oh My Pi is a fork of Pi, but it is its own binary with its own
`~/.omp` config directory, so it gets its own entry rather than sharing
Pi's — a pane running `omp` was previously not detected at all, and
aliasing it onto Pi would have written the bridge to `~/.pi` and offered
`pi --session <id>` to a binary that spells it `--resume`. The status
bridge is the Pi extension the fork inherited (same default-exported
factory, same four lifecycle events), so one template now serves both,
landing at `~/.omp/agent/extensions/tty7/index.ts`.
(#405, reported by @Hanser0521 in #376)
- **The switcher is two columns, and Ctrl+Tab raises it** — it listed
workspaces only, so reaching a tab inside one meant opening the workspace
first. Now workspaces sit on the left and the tabs of whichever one the cursor
is on sit to the right, and Ctrl+Tab raises the panel as a most-recently-used
tab switcher that commits when the modifier comes up, the way IDEA does it.
Picking a workspace or a tab switches this window in place; a second window is
something you ask for, with the platform modifier or **Open in New Window**,
rather than what happens by default. New workspaces also get a codename
("amber-yak") instead of inheriting whatever directory their first shell
started in. (#380)
- **Every shell tty7 can find is in the new-terminal menu, plus one you name** —
the menu lists the shells detected on this machine (Nushell included, on every
platform) and a configurable custom entry with its own arguments, which
survive across shell-inventory rescans. (by @ArnoChenFx in #311)
- **Windows Explorer context menus** — an optional **Open in tty7** verb for a
directory background and for a folder, registered from the installer rather
than from Settings: writing shell verbs is an install-time decision, which is
where VS Code and Git for Windows put theirs. A task checkbox drives new
`--register-explorer-menu` / `--unregister-explorer-menu` flags, so the key
layout stays in `core::explorer_context_menu` instead of being copied into the
`.iss`. The uninstaller unregisters unconditionally, so an install that
registered once and was later upgraded without the box ticked does not leave
keys pointing at a deleted exe. (by @ArnoChenFx in #310, #350)
- **`tty7` opens directories in new tabs** — the CLI accepts one or more
directories and opens a tab per directory in the running GUI, restoring a
window if none is open. Paths that do not round-trip through UTF-8 are
rejected rather than opened somewhere else. (by @ArnoChenFx in #308)
- **A bell that both flashes and rings** — `BellMode` gained a `Both` variant
alongside `None`, `Visual` and `Audible`, exposed as a fourth option under
**Settings → Terminal → Bell**. Existing `none` / `visual` / `audible` config
values are unchanged. (by @shihuaidexianyu in #357)
- **Desktop notifications you can click** — a notification now carries the pane
it came from, and clicking it reveals that pane's window, tab and split.
Windows shows a WinRT toast with an `Activated` handler, macOS uses
mac-notification-sys' click response, and both route through the existing tray
dispatch channel; Linux keeps the plain notify-rust path. Titles gained
context — an agent name or the machine, then the workspace — and bodies name
the command or agent alongside the duration, all of it translated. Notification
text is sanitized on every path: it comes off the terminal, and a stray
control byte used to make the Windows toast XML fail to parse and lose the
notification outright. (by @shihuaidexianyu in #373)
- **The bright half of the ANSI palette is rescued when a theme makes it
illegible** — bright colors that fall under the contrast floor on the theme
background are lifted to it, behind a setting so a theme that means it can opt
out. (by @ArnoChenFx in #413)
### Changed
- **The About page is about the app again** — it had grown three sections that
change system state and that nobody looks for under "About": a PATH install, a
registry write, and a daemon restart. The `tty7` CLI toggle moves to
**Agents**, which already describes tty7 ↔ agent integration in the other
direction (hooks reporting session status). The Windows Explorer context menu
moves to the installer. Server restart stays — it is about the app itself.
What is left is trimmed to what it is for: the marketing paragraph goes, the
update and server explanations drop from multi-sentence accounts of internals
to one line each, the tech credits move to the bottom, and the update toggle
renders with `settings_row` like every other switch. Eight strings the page
had skipped are now localized. (#350, #358)
- **The close-confirmation setting is gone** — closing a window with live panes
always confirms. (#383)
- **The Outline right panel is gone** — with all of its wiring: the tab, the
action, the palette command, the keymap arm and four i18n keys. An existing
config value of `outline` falls back to the default Info tab, and a user
keybinding naming `ShowRightPanelOutline` degrades to a logged warning rather
than breaking the keymap. (#375, requested by @shihuaidexianyu in #374)
- **Chinese terminology, corrected throughout** — the worst one collided two
concepts: a git worktree was translated as 工作区, the same word tty7 uses for
a workspace, so "Remove Worktree" read as "delete this workspace" inside a
destructive confirmation. Worktree is 工作树 now and workspace keeps 工作区 to
itself. Also: Forget password means "clear the stored password", not password
recovery; the SSH auth mode **Agent** was 代理, the same word as the proxy
fields beside it; an SSH profile is a saved host (主机配置), not a file on
disk; scrollback was 回滚, which means rollback — the opposite direction; and
tty7's own server is now written `server`, the way `shell`, `pane` and `agent`
already were, while 服务器 stays where it means the SSH host being connected
to. (#334, #350, #417)
- **Kitty graphics frames cost one copy less** — a re-transmitting sender like
terminal-browser sends a fresh full-window frame per rendered frame, ~26 MiB
of RGBA at Retina resolution, and the client copied that buffer twice on the
way to the atlas. The decode now consumes the frame `Vec` the reader already
owns off the socket and drains the header off the front, and the uncompressed
fast path moves the pixel buffer out so the R↔B swap happens in place. On a
3216×2160 frame the decode-and-normalize step drops from ~1.78 ms to ~0.89 ms
— about 53 ms/s at 60fps. (by @ayamir in #388)
- **Powerline separators are drawn with more curve segments**, so the
half-circle caps read as circles at large font sizes.
(by @cwatanab in #335, by @ArnoChenFx in #341)
- **macOS declares its privacy intents** — tty7 shipped no
`NS*UsageDescription` keys, so macOS denied a pane's child process access to a
protected folder (Containers, Mail, Messages, Calendar) with a repeated prompt
instead of a single clear grant. The bundle now declares the folder, volume
and device usage strings its peers declare, kitty-style, and the Full Disk
Access manual grant is documented in the feature notes. No entitlement is
added: the hardened-runtime automation check runs against the process actually
sending an Apple event, which is the pane's child carrying its own signature,
so granting one would only have widened what injected code could reach.
(by @Gabyran in #323)
### Fixed
- **The ConPTY resize fix now actually engages** — the one-line opt-in that
switches Windows panes onto conhost's resize model was lost from the
working tree between verification and commit, and nothing caught it: the
field defaults to off in the vendored `alacritty_terminal`, so the build,
the test suite, and CI all stayed green while the previous nightly
shipped with the semantics half of the fix disabled and the maximize
garbling intact. The flag is back, and a regression test now pins the
wiring itself so the flag can't silently drop again. (#419)
- **Maximizing a Windows pane no longer shreds it** — two separate resize
disagreements stacked up here, and the visible one was the second.
ConPTY emits no repaint after a resize; conhost silently re-anchors its
own layout and keeps painting with absolute cursor addresses computed
against it. Measured against a live ConPTY: growing the window keeps
rows and cursor pinned and opens blank rows at the bottom — nothing is
restored from scrollback — and shrinking scrolls the last *written* row
(PSReadLine parks a continuation hint below the prompt) to the new
bottom. The grid resized the alacritty way instead: growing pulled
scrollback back into view and pushed the cursor down by that many rows.
After a maximize the two layouts disagreed by exactly the pulled row
count, so every later absolute-CUP paint — PSReadLine redraws the prompt
that way per keystroke — landed mid-screen inside the old output. The
vendored `alacritty_terminal` now has a `conpty_resize` mode that
mirrors conhost's model for the primary screen (the alternate screen
keeps stock behavior; its application repaints itself), and every pane
on Windows opts in — a shell, `wsl.exe`, and `ssh.exe` are all presented
through conhost alike. The cost is Windows-Terminal-standard behavior:
maximizing no longer reveals extra scrollback below the fold.
Separately, a resize during a burst of output reflowed the grid ahead of
the backlog: the daemon can hold up to 16 MiB of queued old-width bytes,
and the client parsed them into the already-resized grid. The daemon now
echoes a `Size` frame to the controller at the exact stream position
where the PTY changed geometry — the same geometry-tagging the reattach
replay has always used — and the client defers its reflow to that
marker, so every queued byte parses into the grid it was rendered for.
Observers, which already received live `Size` frames but only applied
them after a replay snapshot, follow the controller's resizes at the
right moment too. The client keeps the reflow-at-request-time path
against an older daemon that never echoes (new `resize-echo` feature
probe), and on remote routes, where only the local daemon's features are
known — a current remote daemon's echo lands there as a harmless
same-size no-op, and probing per connection is the follow-up that
extends deferral to remote panes. (#415)
- **The whole window no longer hangs after a remote workspace reconnects** — on
Windows, `shutdown()` does not wake a thread parked in a blocking `read()` on
the same socket the way it does on unix, and pane teardown shut the writer
down and then *joined* the reader, counting on that wake-up. When the peer
stayed silent — a routed pane whose SSH leg went zombie: nothing arrives, no
FIN ever comes — the join blocked its caller, which was the UI thread. That is
the "not responding" freeze right after a reconnect.
Teardown now sets a per-reader quit flag and abandons the thread instead of
joining it. The reader's read always times out within 500 ms so a parked
reader notices the flag promptly, and it checks the flag once per frame, so a
retired reader processes nothing at all — without that, a buffered `Exited`
arriving down an abandoned link could close the pane that had just adopted its
replacement. The reader teardown had no Windows test coverage at all before
this; the new tests block for over three seconds against the old code. (#407)
- **A Windows update that reported success and left the old build** — the
updater stopped the daemon and started the Inno installer the moment the
daemon's endpoint disappeared, but the endpoint going away is not the same
event as the images being released. The ConPTY hosts (`OpenConsole.exe`) are
the daemon's children, not the shells', so the per-pane kill never reached
them, and the daemon's `exit(0)` skipped every destructor that would have
closed them: they kept the installed `OpenConsole.exe` open for seconds after
`--stop-daemon` returned. Silent Setup then hit the lock, took the suppressed
dialog's default (Abort), and the recovery relaunched the old build. A daemon
that died without cleaning up made it permanent — its orphaned hosts survive
indefinitely, which is the `DeleteFile` code 5 users hit even after "closing
everything".
Both shapes were reproduced in isolation first: with a pane open,
`--stop-daemon` returned about a second in while `OpenConsole.exe` stayed
locked for another 1.4 s; after killing the daemon outright, the orphaned host
held the lock forever. The shutdown now finishes what it starts at every layer
that can be the last one standing — the daemon reaps its descendants and waits
for them while the endpoint is still up, `stop()` waits for the recorded pid to
actually exit rather than merely stop listening, and
`--stop-daemon --update-install-dir` also terminates anything still running
from the installation directory and only returns once the `.exe`/`.dll` images
there open for writing, naming the holdouts if they never do. The updater runs
that clearing itself before invoking Setup, so a directory that cannot be
cleared fails with a cause in `update.log` instead of Inno's bare
"DeleteFile failed; code 5".
Three smaller holes closed with it: the macOS updater waits for its parent by
watching `getppid()` reparent to launchd rather than polling `kill(pid, 0)`,
which a recycled pid could satisfy forever; a Windows update guard makes
`ensure_running` refuse to spawn a daemon mid-install, so a `tty7` CLI call
cannot relock the images Setup is replacing; and a Windows portable backup
carries an incomplete marker from the first file move until the replacement
lands, so an interrupted update is reported at the next launch instead of
leaving a silently mixed installation. (#403)
- **A newly installed command works in the next pane** — Windows hands every
process a private copy of the environment block at `CreateProcess` time and
never updates it, so a daemon that had been up since before an installer
edited `HKCU\Environment` gave a brand-new pane its startup `PATH` and the
freshly installed command was unresolvable until tty7 restarted. Windows
Terminal launched from Explorer found it, because Explorer rebuilds its own
block when it sees `WM_SETTINGCHANGE`.
Rather than chase broadcast messages, the daemon re-reads the two hives
Windows itself composes an environment from — machine
`Session Manager\Environment` and `HKCU\Environment` — at the moment a pane is
spawned. The merge is a pure function over (machine, user, process, configured
overrides), so the semantics are unit-testable without a registry: names are
keyed case-insensitively so `Path` and `PATH` collapse into one variable;
`PATH` and `PSModulePath` are *combined*, machine first, which is what Windows
does and what keeps a per-user install from shadowing the system half;
`REG_EXPAND_SZ` values are expanded against the merged map with a depth bound;
the machine hive's `USERNAME=SYSTEM` is dropped as Windows drops it; and
directories the daemon's own `PATH` held that neither hive lists stay
reachable, appended behind the registry entries — freshening `PATH` should
only ever add resolvable commands. (#349, reported by @yhzhu99 in #333)
- **Windows panes can answer a background-color query** — the in-box conhost
swallows a pane process's OSC 11 query, so it never reached tty7's emulator
and no reply was written back: applications that pick a light or dark UI from
the terminal background rendered a dark UI under a light theme. tty7 already
answers OSC 10/11/12 from the live theme, so nothing was missing but a
pseudoconsole that forwards the question. Microsoft ships one as a
redistributable and `portable-pty` already prefers a sideloaded `conpty.dll`
over kernel32's, so this is packaging rather than code: the pair goes beside
`tty7-app.exe`.
Measured on Windows 11 26200, same binary, only the pair added beside it: with
the in-box host the client times out with no reply; with the bundled ConPTY a
real pane reads back `rgb:efef/f1f1/f5f5` under `catppuccin_latte`, which is
that preset's exact background. The two files are one supported unit, so the
release verifier fails a package carrying only one, a mismatched pair, or a
DLL without its MIT notice, and `build.rs` stages them beside cargo's output
so a development build does not quietly run on the in-box host.
(#360, reported by @yhzhu99 in #345)
- **A second cursor no longer blinks in the wrong place on Windows** —
conhost's VT renderer brackets every frame it paints with `?25l` … `?25h` so
the cursor does not flicker across the repaint, and it moves the cursor
explicitly just before the show only on the frames where it painted the
cursor. On the others the show commits wherever the last erase or write left
it, and the cursor blinks there until conhost's next frame moves it back. tty7
repaints when a batch of pty output lands, so it draws that cursor for a frame
— and a TUI that repaints on a spinner produces one every tick.
Measured on Windows 11 26200 from a raw ConPTY capture of a Codex session at
110×30: 295 ms of cursor-visible dwell across 42 frames parked at the end of
the status line, each stray corrected 7–15 ms later by the following frame.
A scanner now pairs each hide with its show and marks the show as *parked*
when the run in between moved the cursor around to paint but did not end on a
move — nothing chose the cell it is about to appear on — and restores the cell
the cursor stood on when it went invisible, which is where the correcting
frame would have put it anyway. A hide and a show more than 100 ms apart are
an application keeping the cursor off for the length of some work, not a
renderer bracketing one frame, and are left alone. This is inert where the
bundled ConPTY redistributable is in play; it earns its keep on hosts without
it, notably a remote Windows server. (#362)
- **Windows toasts are tty7's, not PowerShell's** — notifications showed the
PowerShell icon and name because tty7 had no AUMID of its own registered. It
now brands the process and refreshes the single per-user `tty7.lnk` Inno's
default install owns — but only when that shortcut is not already ours, never
from a cargo build directory, and never as a second per-user copy beside an
all-users install, all three of which the first cut got wrong on a real
machine. The shell indexes a new `.lnk` asynchronously and `Toast::show()`
reports success while dropping the toast for an AUMID it has not seen yet, so
toasts keep the PowerShell identity for half a minute after tty7 writes one:
ugly beats invisible. (by @shihuaidexianyu in #340, reported in #339)
- **Windows panes are told whether the window is light or dark** — the daemon
needs to know when it spawns a pane, because ConPTY drops the child's OSC 11
query before tty7's emulator can answer it. It was reading that from a dead
`Config::theme` field, which meant the GUI had to rewrite the user's
`config.json` every time the effective preset changed sides. The hint moves to
its own `appearance.json` in the data dir: derived state, written by the
process that paints the window and read by the one that has to describe it.
Absent, unreadable and unparsable all read as light — what the default preset
is — so a daemon that starts before the GUI has ever applied a theme describes
the default window instead of guessing. (by @yhzhu99 in #332)
- **Scoop shims work again when tty7 is launched from a hardened Windows
shell broker** — some brokers enforce `ProcessRedirectionTrustPolicy` on
what they start. The daemon inherited it, every ConPTY shell under the
daemon inherited it in turn, and PowerShell could then no longer traverse
a user-created junction — which is exactly what Scoop's `current` links
are. `oh-my-posh` and `fzf` died with `Shim: Could not determine if target
is a GUI app`. Windows Terminal was unaffected because its process tree
never picked the policy up in the first place.
The policy cannot be relaxed once it is on, so the fix is to not inherit
it: when tty7 detects the enforcing bit, it creates the daemon with
`PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` naming the interactive desktop
shell, which supplies the ordinary desktop token, device map, and
mitigation policy. Everything else about the spawn is unchanged, and the
ordinary path still runs whenever the policy is absent — or whenever the
desktop shell cannot be borrowed, in which case tty7 logs a warning and
starts degraded rather than not starting at all. Note the daemon's token on
the clean-parent path derives from Explorer, so an elevated tty7 starts a
medium-integrity daemon. (by @ArnoChenFx in #292)
- **A pane's last line of output no longer loses the race with its exit on
Windows** — the process-exit monitor could observe a short-lived shell
exiting before the ConPTY reader had delivered its final frame, so
`Exited` reached clients ahead of the output that preceded it. The
monitor now releases the pseudoconsole and lets the reader, which reports
only after it has forwarded everything up to EOF, announce the death. A
bounded window behind it still covers the case where EOF never arrives —
a grandchild holding the ConPTY output pipe open keeps the shell's own
exit from ever closing it. (by @ArnoChenFx in #292)
- **A resumed agent keeps the flags it was launched with, on Windows** — on
macOS and Linux the daemon reads the foreground process's real argv, so
`resume_command` can replay a launch flag like
`--dangerously-skip-permissions`. The Windows path detects the agent from the
OSC 133;C command capture but threw the command line away, stamping an empty
argv, so every resumed agent came back bare. The captured command is now
tokenized (case preserved, quotes trimmed, the PowerShell call operator
dropped) and stamped as the pane's launch argv; fork commands share that
source and are fixed by the same change. (#406)
- **Codex hook commands work under PowerShell on Windows** — the installed hook
command is written as a PATH-resolvable bare name rather than a path
PowerShell would not run. (by @shihuaidexianyu in #298)
- **Switching workspaces no longer destroys live sessions** — it rebuilt every
pane it was asked to restore, and a window that rebuilt nothing then deleted
the workspace outright, tree and store both. Three separate guesses, each one
authorizing an irreversible act.
`session_from_tree` erased a pane's id when the tree said `live: false`. That
flag is a cached observation from another process, reloaded as false on every
server start, so a quiet pane read as dead while its shell was running: the
restore had nothing to attach to and spawned a fresh shell over it. Two
servers could also start against one config dir — `run_with` decided another
server was dead by failing to connect once, then unlinked its socket and bound
its own, and the loser kept `control.sock` with an empty pane registry, so
`MachineGet` reported every pane dead and nothing logged an error. And
`finish_hydration` marked a window informed before the rebuild and without
looking at the result, while `tabs_from_session` drops any tab whose panes all
fail to start — which is every tab when the pane socket is unreachable —
leaving a window that was empty and authoritative at once, so the next switch
deleted a workspace with ten live tabs.
Each is now settled by whoever holds the truth: attaching decides whether a
pane is there, an advisory lock decides which process is the server, and a
deletion needs the machine's own mirror to agree that the workspace is empty.
A corrupt `views.json` is quarantined the way `machine.json` already was,
rather than logged and overwritten by the next save. (#410)
- **Tab in a WSL pane completes paths again** — the pane's filesystem is
foreign, so the completion engine had no cwd to list and handed every Tab back
to bash. A WSL pane's POSIX cwd (OSC 7) is now translated to the distro's
`\\wsl$` share, which this process can read like any directory, while
everything that would consult *this* machine stays switched off: no `PATH`
binaries in the command position, no generator scripts, and `~` is left to the
shell, since it names the distro's home. Absolute words stay inside the share,
so `ls /etc<Tab>` lists the distro's `/etc` and not `C:\etc`. A `wsl.exe` pane
spawned without `--distribution` resolves the default distro from the registry
rather than carrying an empty placeholder.
The follow-up fixed the gate that had made this unreachable for the case it
was written for: a pane in a WSL *workspace* is served by the daemon inside
the distro, so its host id is never `LOCAL`, and an early locality check threw
those panes away. The check belongs only to the `wsl.exe`-in-a-local-workspace
branch — a remote host's WSL pane must not read a same-named share here —
while a WSL workspace is this computer's distro by construction. (#408, #418)
- **A WSL machine can restart and replace its server** — the machine menu
offered "restart server" only when the target was SSH, so a distro's row had
just "new workspace" and "disconnect", and the router refused the action for
anything else even though `restart_wsl_daemon` had been sitting unused since it
was written. A distro's server is installed and launched from this computer
exactly like an SSH one; only the transport differs. Both actions now have WSL
arms, so the mismatch dialog's **Update Server** is no longer a dead end
there. A `--stdio` workspace still has nothing to restart: it is whatever
program the user named. (#417)
- **The mismatch dialog replaces the server binary instead of just restarting
it** — the old **Restart Server** button restarted the same incompatible
daemon, leaving the user on the same mismatch afterwards. It now replaces the
binary and then restarts, and says so: **Update Server**.
(by @shihuaidexianyu in #352, reported in #351)
- **A version-mismatched remote server is described as old, not as
not-tty7** — a handshake refused on the control dialect was shown with the
protocol layer's own wording: "java answered, but not as a tty7 server:
control peer (build …) speaks control v4, this build speaks v5". The far end
*is* tty7; it is a build on the other side of a dialect bump, and the reader
cannot act on the dialect numbers either way. The message now states which
side is behind, and the button names the action. The failure that followed was
also invisible: the switcher paints a failed `connect` in preference to stored
host errors, and restarting or replacing a server cleared only the latter, so
whatever went wrong during the install was covered by the complaint that
started it. (#384)
- **Remote server errors appear under the machine they came from** — restart and
replace failures were reported through a global modal, which mixed errors from
different machines together and blocked the UI. They are now stored per host
and surfaced in that host's switcher group, which expands to show them, with a
Dismiss button scoped to its own host and a clear on the next connect so a
later success does not leave a stale failure on screen. A failure raised where
there is no switcher to put it in — the window menu's restart command, or a
mismatch hit mid-connect — still falls back to the modal.
(by @shihuaidexianyu in #354)
- **An SSH remote install uses the bundled server before reaching for the
network** — it only ever checked the release download path and ignored the
server binary already shipped beside the Windows executable, which WSL was
using. The bundled binary is now auto-discovered and the GitHub release
download is the fallback for when no matching local asset exists. The explicit
path keeps its strict no-fallback behavior, and WSL remains bundled-only.
(by @shihuaidexianyu in #344)
- **A server that is only a control dialect behind is noticed at launch** — the
pane protocol and the control dialect are versioned apart and the launch check
only compared the first, so a server from before the v4-to-v5 control bump
answered the pane handshake with this build's own number and was waved through
as ours while every machine-tree call was refused: the window opened with no
tabs and the only trace was a log line the default config does not write
anywhere. The control socket is asked too now, and the restart prompt says
which version disagrees and picks its wording by which side is ahead, rather
than calling a newer server old. A window that still opens empty says why —
once per window, latched, since a failed pull is retried from every
fifteen-second sync. (#387)
- **"Keep Shells" is no longer offered beside a mismatched server** — both
handshakes compare their version for equality and hang up on anything else, so
a server whose number disagrees cannot be talked round, and that button
offered a state that does not work: panes still spawn while every machine-tree
call is refused, which is how a window opens with no tabs and saves none of
the ones you make. The choice is now restart or quit. Quit goes first, because
it destroys nothing — the server and every shell under it keep running — and
because this prompt arrives unasked at launch, the moment a stray Return is
likeliest. A prompt dismissed without an answer re-arms instead of falling
through. (#389)
- **Pasting a screenshot into a remote pane works** — the clipboard-image paste
that stages a file and hands the agent its path was built off macOS only,
because a local macOS agent reads the system clipboard itself when it sees
Ctrl+V and that carries the image at full fidelity. The reasoning stops at the
pane boundary: an agent in an SSH pane or a remote workspace reads the
clipboard of the host *it* runs on, which never holds this machine's
screenshot, so the paste did nothing at all. Remote panes now stage and upload
on every platform, while a local macOS pane keeps its direct path untouched.
macOS screenshots arrive as TIFF, which agent vision rejects the same way it
rejects a Windows BMP, so those transcode to PNG on the way out.
Staging is hardened on the remote side. `/tmp/tty7-clipboard-<user>` is a
predictable name in a world-writable directory: any local account on the
remote host could pre-create it, and the `Mkdir` result was discarded, so tty7
would have uploaded into a directory someone else owned — readable by them,
and swappable for another image before the pane's agent opened the path.
Images now stage in `$HOME/.cache/tty7/clipboard` resolved from the session's
own `realpath .`, and the directory is verified before anything goes into it:
a symlink is refused outright, a `chmod 0700` the daemon watched succeed is
the ownership proof, and a following `stat` must report exactly `0700`. Any
doubt is a hard failure that falls back to pasting the local path. The whole
path also moves off the UI thread, which is what lets a failed upload notify
once, naming the host and the reason, instead of leaving a dangling remote
path in the line and saying nothing.
(by @shihuaidexianyu in #338, reported in #337; #399)
- **A WSL pane is handed a path it can actually open** — the same paste staged
its file on the Windows side and pasted the Windows name for it, so an agent in
WSL got `C:\Users\…\paste-1.png` and found nothing there. A WSL pane shares
this machine's disk but not its path syntax, so there is nothing to upload and
only a name to rewrite: the paste now carries the automount view,
`/mnt/c/Users/…`. A path with no mapping — a UNC temp directory — keeps the
Windows name, which at least says where the file went. (#399)
- **Arrow keys reach ncurses applications in the form they asked for** — Arrow,
Home and End were always sent as their CSI form, no matter what the foreground
program had requested. Programs that turn on DECCKM via `smkx` — which is
every ncurses full-screen app — expect the SS3 form, because that is what
`xterm-256color` spells `kcuu1` and friends as, and ncurses matches terminfo
byte for byte. htop was the report: ncurses failed to match `\E[A`, handed the
bytes to htop one at a time, and htop binds `[` to "lower priority", so every
Up or Down bumped the selected process's nice value instead of moving the
selection. The same breakage hit ncdu, mc, dialog, menuconfig and nmtui;
shells were unaffected because readline and zle bind both forms.
Named keys now also carry their modifiers the way terminfo declares them
(`kLFT=\E[1;2D`, `kUP5=\E[1;5A`, `kDC3=\E[3;3~`) instead of dropping
Shift/Ctrl entirely and prefixing Alt with a bare ESC. Cmd stays out of the
modifier parameter — xterm has no encoding for it.
(#366, reported by @hak0 in #361)
- **A right-click goes to the application when mouse reporting is on** — a TUI
that turns mouse reporting on (vim with `set mouse=a`, lazygit, tmux) draws its
own right-button menus, and tty7 was delivering one right-click to both
consumers: the press was forwarded to the application *and* tty7's own context
menu popped over the top of it. One pure predicate now decides, called from
both sides, so one click can only ever feed one consumer; Shift stays the
escape hatch that reaches tty7, the same override it already provides for
selection and for the wheel. (#347)
- **`$SHELL` names the shell the pane actually runs** — a pane configured to run
fish still advertised the login shell, because the pane environment injected
`TERM`, the `TTY7_*` markers and `TERM_PROGRAM` but never touched `SHELL`, so
the pane inherited the GUI session's login-time snapshot. Everything that
spawns "the user's shell" read that: tmux's `default-shell` started zsh inside
a fish pane, and so did `sudo -s`, an editor's shell escape, and any coding
agent picking a quoting dialect from `$SHELL`. The failure is silent — fish
rejects the bash line, the agent's sentinel file never appears, and the
rejected text stays in the line editor to concatenate onto the next send.
`SHELL` is now injected alongside the other markers, set to the absolute path
of the program the pane is about to exec, read off argv rather than off the
shell tty7 resolved — an argv-replacing integration injection and the
parent-shell override both rewrite argv. Only an absolute path is ever
written: a configured command may be bare so `PATH` decides which install
wins, so a bare name is resolved against the `PATH` the pane will inherit and
skipped when that finds nothing. An explicit `SHELL` in the user's env block
still wins. Windows is deliberately left out — neither cmd nor PowerShell reads
`SHELL`, and the POSIX emulations that do want a POSIX path.
(#348, reported by @nohzafk in #342)
- **The login shell is read from passwd, not from a stale `$SHELL`** — `$SHELL`
is a snapshot the session inherits at login, so `chsh` never moves it: a GUI
launch kept reporting the shell that was current when the user logged in, and
the window's shell menu marked the wrong entry "default" for that whole
stretch. The passwd entry is read instead, via the reentrant `getpwuid_r`,
with `$SHELL` as the fallback. The same change fixes who wins a name in the
menu: `$PATH` is probed before `/etc/shells`, so on a machine with a Homebrew
bash the menu's "bash" is the binary typing `bash` would reach rather than
macOS's 3.2 from 2007. (#278)
- **A forwarding `.bash_profile` no longer sources `.bashrc` twice** — the
rcfile tty7 hands bash replays the login-shell startup chain, but then sourced
`~/.bashrc` unconditionally afterwards. A login shell never does that on its
own: `~/.bashrc` arrives only because the profile that won the chain forwarded
to it, which is how nearly every `~/.bash_profile` is written. The result was
the user's whole `~/.bashrc` running twice per pane — banners printed twice,
completions sourced twice, appends to `PROMPT_COMMAND` stacking up.
`~/.bashrc` joins the same first-match-wins chain, which keeps the fallback
for a `$HOME` with no profile at all. (#279)
- **The macOS locale fallback sets `LANG`, not `LC_CTYPE`** — when a pane
inherits no locale at all, the usual case for a GUI-launched process on macOS,
tty7 derives an installed UTF-8 locale and injects it — but as `LC_CTYPE`,
which backs only character handling. Collation, time and numbers stayed at C,
and bash warned `setlocale: LC_COLLATE: cannot change locale ()` once per
category on every launch; zsh and fish swallowed the failure and merely looked
fine while being just as half-configured. `LANG` backs every category and
still loses to any `LC_*` the user's own rc files set afterwards, which is
what a fallback should do. (#280)
- **Three defects in the inline completion menu** — a candidate was inserted
verbatim, so a directory named `My Documents` completed to `cd My Documents/`,
which the shell resplits into two arguments; candidates are escaped now, with
a leading `~/` left alone since that prefix is the user's own text and escaping
it would stop the home expansion it was typed for. The same escape decides
whether a common-prefix step is safe to write — the prefix shared by
`My Documents` and `My Music` is `My `, and writing it raw both broke the line
and closed the menu on the next keystroke — so a prefix that needs escaping
steps through the candidates instead. A menu fed only by generators stayed
armed forever when nothing matched, swallowing every later Tab instead of
handing the line to the shell; sessions now count their generators and the
last one to answer closes a menu that still has nothing in it. And command
completion scanned *this* machine's `PATH` in a remote pane, so
`system_prof<Tab>` over SSH to Linux offered macOS's `system_profiler` —
builtins are true on any POSIX shell and still go out, the `PATH` scan is now
local-only. (#276)
- **macOS editing shortcuts reach a foreground TUI** — Cmd+Backspace and the
rest of the line-editing chord set are forwarded to the application instead of
being swallowed. (by @loscoy in #304)
- **Ctrl-E accepts a ghost suggestion on the first try** — it accepts a visible
inline history suggestion while keeping end-of-line behavior when none is
shown. (by @vkingw in #329, reported in #315)
- **No stray `^U` after interrupting an agent** — typeahead captured across an
alt-screen boundary was replayed into the line editor after the interrupt.
(by @shihuaidexianyu in #312, reported by @fuchen in #305)
- **The editor is restored after an interrupted tab handoff.** (#290)
- **ANSI dim text and text decorations render** — underline, double underline,
curly, dotted, dashed, overline and strikethrough, and SGR 2 dim.
(#288, #289)
- **Powerline separators have no seam between them** — adjacent separators left
a hairline of background between the two glyphs. A cover quad closes it, and
is skipped when the glyph is dim: the cover and the anti-aliased path overlap
on the closing edge's device pixel, which is a no-op for an opaque foreground
but pushes a dim cell's alpha to 0.884 and tints the neighboring cell's
background. (by @ArnoChenFx in #336)
- **Tokens that bypassed the contrast machinery now have a floor** — sidebar
text, the caret and hairlines were flat blends with no floor, sitting one line
away from tokens that are bisected to hit a target exactly, and semantic inks
were floored against the window background but painted on popovers and sidebar
rows, which sit a step toward the foreground. `sidebar_fg` is floored at 4.5:1
on the sidebar fill it is actually painted on — four builtins landed at
3.35–3.92:1 — the caret is conditioned to 3:1, where the default Light theme
shipped an orange caret on pure white at 2.07:1, `border` keeps its blend but
gains a 1.5:1 floor so a divider is worth the same in every theme, and
semantic inks clear their floor on background, sidebar and popover alike.
Themes that already cleared a floor are untouched. (#400)
- **Smooth scroll steps are aligned with presented frames**, and a queued scroll
animation wakes the window. (by @ArnoChenFx in #414)
- **Kitty graphics frames cannot pile up faster than they decode** — the
unbounded decode channel becomes a bounded latest-frame inbox, so a
full-window sender cannot queue frames faster than they are consumed, and
superseded frames are discarded before being decoded. Only images that reached
the sprite atlas are retired, and remaining atlas entries are evicted when a
pane closes, which keeps hidden terminal-browser tabs and repeated pane
lifecycles from retaining one decoded frame per repaint.
- **The terminal's own context menu and search bar are translated** — the
context menu was the last surface still speaking hardcoded English in a
three-locale app, 14 literals of it, and the search bar had three more with
its Previous / Next / Close buttons icon-only and untooltipped. It also spoke
a fourth vocabulary for one action: "Maximize Pane" for what the menu bar, the
palette and Keybindings all call "Zoom Pane", and "Close Pane" for what is
"Close Pane / Tab" everywhere else — which is the accurate name, since the
action closes the tab when the pane is the last one. (#401)
- **A new tab lands in its repo group on the first frame** instead of appearing
ungrouped for one paint. (by @ArnoChenFx in #411)
- **Settings hover highlights respond immediately**, with a stable row identity
so the highlight does not jump between rows. (by @ArnoChenFx in #313)
- **Overlay scrollbars are softer.** (#293)
- **`tty7 send` submits its Enter outside a paste burst**, so an agent does not
read the newline as part of pasted text. (#322)
- **Closing a tab from the CLI terminates its panes.** (#319)
- **`tty7` diagnoses an unavailable agent hook** instead of failing silently.
(#321)
- **tty7 no longer panics on launch under Wayland on a slow VM** — the
xdg-desktop-portal event source notified windows of the initial color-scheme
and button-layout replies while still holding `client.borrow_mut()`, and those
callbacks re-enter GPUI and reach the same `RefCell`. Whether it fires depends
on whether the portal reply beats window creation, so a VMware Ubuntu guest
lost that race every time. Fixed in the gpui fork for both the Wayland and X11
clients by collecting the window pointers and dropping the borrow before
notifying.
- **The Windows package verifier can read the Inno payload again** — the bundle
script deleted the staging directory as its last act, and the verifier reads
that directory to check what lands in `{app}`, since a compiled `setup.exe`
cannot be read back without innoextract. Every Windows build had failed the
check since it arrived, taking nightly red for two nights, and a stable
release would have failed the same way. Both workflows already expected the
directory to survive — their upload steps name it among the intermediates the
asset globs deliberately skip — so the removal goes rather than the check.
(#368)
## [26.8.1] - 2026-08-01
### Added
- **A remote workspace is a window that is one machine, not a pane that
happens to be SSH'd somewhere** — pick a host from **Home → Connect to
Host** (or the workspace switcher) and tty7 opens a window bound to that
machine: its own tab and pane tree, file browser and editor, repo grouping,
branch line, Changes panel, diff overlay and worktree list, all answered by
*that* machine rather than read off the client's disk. Before this, reaching
a remote box meant an SSH pane from the command palette — a shell and
nothing else. Repo grouping, the diff overlay, the right-panel Changes list,
worktrees: all of it read the client's local disk, so none of it existed
over SSH. The file tree was SFTP browsing, a separate, lesser thing from the
local editor. And the moment you closed the lid or lost the network, the
agent running in that pane died with the connection — there was nothing on
the far side to keep it alive.
A remote workspace fixes all four at once by putting the same code on both
ends. `tty7-server` — a new headless binary built on `tty7-core`, the crate
the GUI's own daemon logic was extracted into — runs on the far machine and
serves the identical `LocalHost` implementation the GUI uses for your own
disk, over one control connection. Because both sides run the *same* code
rather than a client-only reimplementation, a remote workspace has the exact
feature set a local one does, not a cut-down version of it. Sessions live in
that server, not in the window: closing the window detaches rather than
kills, panes and agent conversations keep running on the far side, and
reconnecting — from this laptop or another one — lands back where you left
it.
Local and remote workspaces are two different features, not one with a flag:
a window is never a mix of an SSH pane and a real workspace, and that split
is enforced rather than left to convention. The install itself is one-time
and unprivileged — the server is a static binary pushed over the same
connection, no `sudo`, confirmed once per machine. This is the foundation
the WSL-machines, incremental git-streaming and per-machine `⌃R` history
entries below build on: none of them had anywhere to attach to before this
landed. (#235)
- **`tty7` is now a CLI, not just the app you launch by clicking an icon** —
a new scriptable command-line client, built for coding agents to drive
panes without a window in the way. The GUI binary is renamed `tty7-app`
(bundle id, icons and display name unchanged); `tty7`, the name you type,
is now the CLI. Both are clients of the same `tty7-server` a remote
workspace runs, talking the same control and pane protocols the window
does — an agent reaching through the CLI can do exactly what the CLI
exposes, no more, no less.
| | |
|---|---|
| Hot path | `ls`, `run -- <cmd>` (real exit code, `--keep` keeps the pane around), `new`, `send`, `capture`, `split`, `procs` |
| Addressing | `ws` / `tab` (`@N`) / `pane` (`%N`) / `machine` / `server` |
| Introspection | `agents`, `events` (NDJSON), `status`, `doctor` |
| Global | `-m <machine>` routes over the server's existing SSH/WSL links; `--json` everywhere; implicit context from `TTY7_PANE` / `TTY7_WS` / `TTY7_SOCKET`, injected into every spawned shell |
The server's control dialect grew several additive verbs to carry this: a
read-only `Observe` for watching a pane without owning it, budgeted per
observer; pane exit codes, which previously were always reported `None`
and now actually carry the code; and aggregate `AgentStates` / `Routes` /
`Status` queries. Writing into a pane now hands off cleanly instead of
fighting the previous controller for it — a displaced client gets a clean
EOF and its stale input is dropped, closing a long-standing hazard where
two writers on one pane could leave it looking frozen. `tty7_core::client`
exposes the `ControlClient`/`PaneClient` pair the CLI is built on as a
public library, with zero behavior change to the GUI's own connection code.
There is deliberately no interactive `attach` verb — agents drive panes
with `run` / `send` / `capture` / `events` instead of taking one over
interactively. An `attach` verb was designed and partway built during this
work and then dropped before it ever reached a release, so this is design
history, not a behavior change for anyone — the CLI itself is new in this
release, so there was no previous `tty7 attach` to remove out from under
existing scripts. The protocol-level `Attach` call the GUI's own panes use
is untouched.
Every unimplemented verb (`ws stop`, `machine connect`/`disconnect`, bare
`tty7` launching the GUI) says so in `--help` rather than only failing at
runtime. Along the way, the GUI's own copy was reworded for the same two
concepts the CLI now names — "server" for the background process, "shell"
for what runs in a pane — replacing inconsistent uses of "daemon" and
"session" in the tray, palette and confirmation dialogs (no behavior
change, wording only). (#274)
- **The CLI ships in every install and lands on PATH automatically** —
previously it was built by every release and then discarded: the bundle
scripts copied only `tty7-app`, and the upload glob never reached the CLI
binary. It now ships inside the macOS `.app`, the Linux tarball and
AppImage, and the Windows installer and zip, and a fresh launch puts it
where a shell can find it without you doing anything.
The install is split into two halves on purpose. The **environment half**
prepends the CLI's directory to the GUI process's own PATH before the
server is spawned, so every pane — which inherits its environment from the
server, which inherits it from the GUI — can run `tty7` immediately. It
writes nothing to disk and cannot fail. The **on-disk half** covers typing
`tty7` in some other terminal, and is allowed to fail safely: on Unix it
symlinks into a fixed, ordered list of candidate directories
(`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`,
`~/.cargo/bin`) rather than scanning PATH for the first writable entry —
version-manager shim directories (pyenv, rbenv, asdf, mise) sit at the
front of many people's PATH and are writable, and a binary dropped there
survives only until that tool next rehashes and silently deletes it. On
Windows it appends the app's directory to `HKCU\Environment` through the
registry API rather than `setx`, which truncates and corrupts anything
over 1024 characters, and reads/writes the value as UTF-16 end to end so a
PATH entry Rust can't represent as a `String` survives untouched.
It is deliberately conservative about what it touches: only a symlink (or,
on the AppImage, a copy) this installer itself created is ever replaced —
marked as ours with a small marker file so a user who moves from the
AppImage to the tarball still gets recognized and re-linked rather than
mistaken for someone else's binary — and a real file or a symlink aimed
elsewhere is left alone. An occupied candidate directory doesn't end the
scan, and every platform now reports whether the install actually wins the
lookup (`InstalledShadowed` names whichever `tty7` beats it on PATH).
Debug builds and cargo build trees only get the environment half, so a
`cargo run` can't repoint your real `tty7` at a binary the next `cargo
clean` deletes. The Windows uninstaller removes the PATH entry it added;
on Unix there is no uninstall hook, so the symlink is left dangling and
that limitation is stated in the module docs rather than left to be
discovered. The whole thing turns off from Settings → About, or
`install_cli_on_path: false` in `config.json`. (#277)
- **Pi is a first-class agent, not a fallback one** — Pi panes drew the generic
robot glyph every unbranded agent shares, so a Pi tab was indistinguishable
from an Aider or Qwen one in the sidebar, the tab chip and the tray menu. They
now carry their own avatar on the existing sky accent, status dot unchanged.
The mark is Pi's own, from pi.dev, rescaled to tty7's 24x24 icon grid — its
`prefers-color-scheme` stylesheet dropped, since these avatars are tinted by
the app. Restoring a Pi pane also resumes its conversation now: the tty7
extension reports Pi's session id, and the resume command is
`pi --session <id>` (Pi's `--resume` is a boolean that only opens the
interactive picker), with `--session` / `--session-id` / `--fork` /
`--resume` / `-r` / `--continue` / `-c` stripped off the replayed launch
flags so the restored id wins. A pane launched with `--no-session` is not
resumed at all — that pane never wrote a session to disk, and reopening one
would override the choice to keep it ephemeral. (#240)
- **Fork an agent session** — branch a live agent conversation into a second,
independent one, so a risky direction can be tried without losing the thread
that got you there. tty7 shells the agent's *own* fork command rather than
touching its transcript files: `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — every one checked against the installed CLI's own help.
Agents with no fork tty7 could verify simply don't offer the action, rather
than getting a row that can only produce a usage error. The command carries
the pane's original launch flags exactly as session restore does, and sheds
the stale session-targeting ones so a fork of a fork can't branch twice or
replay an old id as a prompt.
Where the fork lands follows where you asked from: right-click a **pane** and
it asks for a split placement (Right / Left / Down / Up), since a pane-level
ask is a spatial one; right-click a **tab** or a sidebar row and it opens in a
new tab, with no placement question. The command palette and the File menu
carry the new-tab form only — a placement question means nothing when the ask
wasn't a spatial one — while all five, the four directions included, are
bindable in Settings → Keybindings.
A fork needs the session id the agent's hooks report, so the row disables
itself — rather than disappearing — until one arrives, and a remote pane can't
fork at all (the command would run against the *local* agent). Forking while a
turn is in flight is allowed but says so: agents fork from the persisted
transcript, so the turn you're watching won't be in the copy. The parent is
never modified either way. (#241)
- **Your WSL distributions are machines you can open a workspace on** — the
transport for them has been there since remote workspaces landed
(`wsl.exe -d <distro> -- tty7-server --stdio`: no SSH, no address, no
credential, no host key), but nothing offered one, so nothing could reach it.
Every installed distribution now appears in the workspace switcher beside your
saved SSH hosts, and opening one is the whole setup — there is nothing to
configure, because there is nothing that *could* be configured. The row is
named exactly what `wsl -d` calls the distro, since that string is also how
the machine is keyed (`wsl:<distro>`), and searching the switcher for `wsl`
finds all of them.
A distribution is served the Linux `tty7-server` this client shipped with
rather than one downloaded from a release, so the first connect writes it into
`~/.local/share/tty7/bin` inside the distro — with the same one-time
confirmation any other machine gets, and no `sudo` anywhere. A build with no
bundled server (any `cargo build`, and any platform that isn't Windows) says
so and names the directories it looked in.
Two things had to be fixed for this to work at all, both invisible until
something could actually select a distribution. WSL kills what an interop
session started the moment its `wsl.exe` exits, and `setsid` does not make the
new daemon safe instantly — so the launch now holds its invocation open until
the daemon answers, instead of reporting "started but nothing was answering on
the control socket" over a correctly installed binary. And a pane's connection now asks
for the remote's *pane* socket: the flag that says so was added by the SSH
path and by the local `--stdio` one, but never by WSL, so the workspace would
connect and the window would open with a pane that could not reach the machine.
Ports need no forwarding (WSL shares `localhost`, so ⌘-clicking a dev server's
URL just works), and files move over the same `Host` calls every remote
workspace uses, or through `\\wsl$` directly. (#253)
- **Copy Session ID** — the agent's native session id on the clipboard, beside
*Copy Working Directory* in the tab / sidebar context menu, the palette and
the File menu. Codex has no copy-or-duplicate subcommand — forking *is* how
you duplicate a conversation there — so copying the id is what "copy the
session" means: paste it into `codex resume`, a bug report, or another tool.
(#241)
- **Remote panes read big git output incrementally** — `Host` grew a streaming
companion to its buffered `git`, implemented on both the local host and the
remote wire protocol, so a read whose size scales with the work tree no
longer has to exist in memory all at once on either side. The buffered call
stays for the many reads that answer in bytes. A stream that goes silent for
two minutes while the link stays up ends with a timeout rather than parking
its reader forever — the wait is between chunks, not on the whole read, so a
slow-but-alive `git diff` still runs to completion. And the queue *between*
the two ends is bounded as well, not just the reads at either end: a peer that
pushes faster than this side can parse is cut off at 32 MiB of arrears with an
error, rather than quietly reassembling the whole diff in a channel. (#247)
- **Sidebar diff preview is optional** — clicking a sidebar row's `+N −N`
working-tree counts opens the diff overlay, which is the point of them for
most people but not for everyone. Settings → Window & Tabs → *Open diff
preview from sidebar counts* turns the click off (`sidebar_diff_preview` in
`config.json`, on by default). Off, the branch and the counts stay exactly
where they are and read exactly the same; they simply stop being their own
click target, so the press falls through to ordinary tab activation like any
other part of the row. (#247)
- **Kitty graphics protocol, with a shared-memory fast path** — TUIs that
draw images now render inline in a pane. The daemon intercepts kitty
graphics escape sequences (`ESC _G…`) in the pane reader before they reach
scrollback or the client's VT parser — a zero-copy sniff on ordinary
output, allocating only once a chunk actually carries a graphics command —
and answers `a=q` capability queries directly on the PTY so probing
senders see support. Images and deletes travel to the client as compact
binary frames interleaved with normal output in stream order, so an image
lands at the cell the sender drew it at; decoding (inflate, BGRA swap,
atlas placement) runs off the render thread with newest-frame-per-id
coalescing, so a full frame from a graphics-heavy TUI can't stall PTY
output or scrolling. For a local pane, file and POSIX shared-memory
transfers are honored directly — the daemon reads the object and hands the
client raw pixels, skipping the zlib inflate the compressed-inline path
would otherwise force. A remote (SSH) pane keeps refusing shm/file and
rides the compressed-inline path over the tunnel instead. (by @ayamir in
#272)
### Changed
- **Windows and Linux stop taking keys the shell needs** — `secondary` means
Cmd on macOS and Ctrl everywhere else, and the default keymap was carried over
from macOS unchanged. That put window actions straight on top of terminal
control codes: Ctrl+D could not send EOF (it split the pane), Ctrl+[ could not
send ESC (it cycled panes), and Ctrl+W, Ctrl+K, Ctrl+P, Ctrl+J, Ctrl+T,
Ctrl+Q and Ctrl+S were all spoken for. These are window-level bindings with no
context, so they matched before the terminal ever saw the key — the code that
sends EOF was there, just unreachable.
Off macOS the rule is now that `ctrl-<letter>`, `ctrl-[`, `ctrl-]`, `ctrl-\`
and `ctrl-space` belong to the terminal, and window actions live on
`ctrl-shift-*` — the convention GNOME Terminal, Konsole, Windows Terminal and
WezTerm already share. A test enforces it, so the next binding added cannot
quietly reintroduce the problem.
| action | was | now |
|---|---|---|
| Focus previous / next pane | Ctrl+[ / Ctrl+] | Ctrl+Shift+[ / Ctrl+Shift+] |
| Split right / down | Ctrl+D / Ctrl+Shift+D | Ctrl+Shift+D / Ctrl+Alt+Shift+D |
| Close tab | Ctrl+W | Ctrl+Shift+W |
| New tab | Ctrl+T | Ctrl+Shift+T |
| Reopen closed tab | Ctrl+Shift+T | Alt+Shift+T |
| Clear scrollback | Ctrl+K | Ctrl+Shift+K |
| Command palette | Ctrl+P | Ctrl+Shift+P |
| Toggle right panel | Ctrl+J | Ctrl+Shift+J |
| Toggle left panel | unbound | Ctrl+Shift+B |
| Quit | Ctrl+Q | Ctrl+Shift+Q |
| Fullscreen | Ctrl+Enter | F11 |
| Activate tab 1-9 | Ctrl+1-9 | Alt+1-9 |
| Focus pane by direction | Ctrl+Alt+arrow | Alt+arrow |
Ctrl+S keeps saving in the code panel but now falls through to the terminal
when the editor does not have focus, so a shell still receives XOFF.
Ctrl+C, Ctrl+V and Ctrl+X are unchanged — Ctrl+C still copies only when there
is a selection and sends SIGINT otherwise — and **Shift+Insert** now pastes.
With Alt+←/→ focusing panes (Windows Terminal's default), word-by-word
movement in the prompt editor lives on Ctrl+←/→ — the Windows/Linux text
convention, which already worked — with Ctrl+Shift+←/→ and Alt+Shift+←/→
both selecting by word. macOS bindings are untouched. Anything you rebound
yourself still wins; only the defaults moved. (#270)
- **Ctrl+Shift+C and Ctrl+Shift+V copy and paste, like every GUI terminal** —
the chord GNOME Terminal, Konsole, Windows Terminal and WezTerm all teach.
Ctrl+C/Ctrl+V still work as before; Shift+Insert stays a second paste chord,
and moving Paste to a key of your own retires both defaults together. Copy
and Paste now sit in the default keymap table rather than being installed
behind the scenes, so Settings → Keybindings lists them, records new chords
for them, and warns when another binding would collide — previously
Shift+Insert was invisible there and could not be reassigned. (#271)
- **The machine that runs your panes now owns their layout** — the workspace,
tab and pane tree has moved out of the app and into the background service, so
one machine has one tree that every client of it reads: the window on it, a
laptop connected to it across the world, and (next) the session CLI. Clients
send named edits ("split this pane", "rename that tab") and receive the
incremental changes other clients make, which is what lets two windows on one
machine both land their work instead of the last one to save winning. A pane's
working directory, its coding agent and whether it is still running are now
observed by the service that owns the PTY rather than remembered by whichever
client last wrote a file — so after a service restart every pane is *known*
dead and revives into its recorded directory with its agent conversation
resumed, with no guessing about which saved ids survived.
Two consequences worth knowing before you upgrade:
- **Saved layouts do not carry over.** The tree is a new file
(`~/.local/share/tty7/machine.json`) and the old `session.json` is not read;
the upgrade also replaces the background service, which ends the panes it was
holding. The first launch after upgrading comes up on a fresh workspace, and
tabs from before it are not recoverable. `views.json` (window geometry and
which workspaces you had open) replaces `session.json` for the client's own
half; the old file is left on disk, unread.
- **Windows keeps its panes but not its layout, for now.** The tree is served
over the same control channel remote machines use, and that channel is
Unix-socket-only today, so on Windows tabs do not come back across a restart.
Panes, splits, agents and shell integration are unaffected within a session.
(#260)
- **The prompt editor's soft newline is now a rebindable action** — `Shift+Enter`
and `Alt+Enter` have inserted a literal newline into the command editor since
the multi-line prompt editor landed, but the chords were hardcoded in the key
handler: there was no `InsertNewline` to name in `keybindings`, and no way to
move the gesture to a chord of your own. The behaviour is unchanged out of the
box — both chords still insert, plain `Enter` still submits the whole buffer —
but it now runs through an `InsertNewline` action, so it appears in Settings →
Keybindings and can be rebound like anything else, and rebinding it retires
both defaults. Only the prompt editor answers it; with a full-screen program on
the pane the chord reaches the application exactly as before. `⌘⏎` fullscreen
and `⌘⇧⏎` pane zoom are untouched.
Two smaller behaviour changes come with it, both aligning on what other
terminals do. With a completion menu open, the newline chords now insert a
newline and close the menu instead of accepting the highlighted candidate —
plain `Enter` remains the key that accepts it. And `Shift+Alt+Enter`, which
the old modifier test caught by accident, now submits like any other `Enter`:
keybindings match modifiers exactly, and no terminal treats that three-key
chord as a newline. (#246)
- **Every header in the window moves it now** — grabbing the window by a header
is a property of the whole app rather than a per-surface feature, so you never
have to learn which rows happen to be draggable. The detail panel's section
title joins the caption rows that already were, wherever the panel draws one —
every tab off macOS, where that row is also the panel's tab switcher, and the
remote Files header on macOS. In horizontal-tab mode the strip also keeps a
bare 80px slice of caption for grabbing: its spacer was a flexible one with no
minimum, so it collapsed to exactly 0px once the tab chips saturated the row —
around 7-8 tabs on a 1440px window — leaving nothing to grab but three 6px
gaps and a hairline above and below the chips. The chip row's fixed-chrome
reserve is corrected to match: it was a flat 100px, sized when the corner held
a 30px "+" and a 30px "⋯", and was never raised when the workspace chip
absorbed the "⋯" menu in #169/#188 — so the row's width budget was ~20px of a
lie. It now measures the group it is reserving for, ~121px of fixed chrome plus
the 80px grab handle, so chips reach their minimum width and truncate a tab or
two sooner. (#252)
- **Large working-tree diffs no longer stall the window** — the diff overlay
had five costs that all scaled with the size of the tree rather than with
what it could show. Four were named by issue #239's source-level analysis;
the fifth, the untracked list, turned up while fixing those. Measured on a
300-file, 90 000-line, 4.5 MB working-tree diff:
- The full `git diff HEAD` was read into one `String` before parsing began.
It is now read incrementally through a new streaming call on `Host`, so
what is held at once is one 64 KiB read buffer plus the line being
reassembled rather than the whole diff — and that line is itself capped at
1 MiB, because a line is only complete at its newline and a minified bundle
is one line of many megabytes. Measured on this repository's own `git log -p
-n 400` (8 269 409 bytes of real git output, release build): 8.3 MB resident
→ 64 KiB, and *faster* end to end — 829 ms buffered (817 ms read + 12 ms
parse) → 557 ms streamed, because parsing now overlaps with git producing
output instead of waiting for all of it.
- The parsed snapshot was deep-cloned onto every tab's overlay *on the UI
update path*. It is now shared behind an `Arc`: 1.99 ms of main-thread
copying per holder → 10 ns (426 µs → 10 ns even at the new retention
budget).
- The per-file 2000-line cap bounded one pathological file but not the sum,
so two hundred ordinary files could retain 90 000 `DiffLine`s. A repo-wide
budget now caps retained lines and files-with-hunks — 90 000 lines / 6.2 MiB
of line text → 20 000 lines / 1.2 MiB — while `+N −N` keeps counting the
whole diff, so the numbers stay exact and the overlay doesn't re-probe in a
loop chasing a total it can no longer reach.
- The untracked list escaped all of the above: `git ls-files --others` reports
every path not yet ignored, and one un-ignored `node_modules` reached the
overlay as tens of thousands of rows without touching the diff at all. It is
now streamed, capped where it is retained and again where it is rendered —
while the count shown stays the true total, so a capped list never reads as
files having vanished. It deliberately does *not* drive the collapse-
everything rule below: folding file bodies shut removes no untracked rows,
so a tree with an un-ignored dependency directory and three edited files
would have hidden the three cheap things and kept the expensive one.
- The 400-line auto-collapse rule was per-file, so sixty medium files all
opened at once (thousands of side-by-side rows, none of them individually
large). Past a repo-wide total the overlay now opens with every file
collapsed — zero rows built — leads with a summary saying the diff is too
large to render efficiently, and points at expanding individual files or
`git diff` in the terminal. That total counts the context lines git prints
around every hunk, which is what is actually rendered, so it sits several
times above the `+N −N` figure at which it would otherwise fire on an
ordinary afternoon's work.
- **One `git diff` per repository, not two** — the Changes panel and the diff
overlay each ran their own full-diff probe and kept their own snapshot of the
same repository. They now share one probe and one snapshot, so opening both
costs one shell-out and one parse, and opening the overlay while the panel is
already showing that repo paints immediately instead of re-probing. (#247)
### Fixed
- **A remote workspace no longer loses or crosses its own layout on reopen
or restart** — two related failure modes in the same area, closed
together. Reopening a remote workspace, or coming back to one whose
`tty7-server` had been replaced, could land on permanently `disconnected`
panes with the coding-agent conversations gone: there was no way to tell a
genuinely restarted server (which needs a rebuild from the saved layout
and fresh shells) from a link that had merely blinked (which just needs
re-attaching). A new `ControlHelloOk.instance` identifies the server
*process* so a reconnect can tell the two apart — an absent instance reads
as unknown, never as a restart — attach failures now fall back to
spawning instead of stranding the pane in a link-down state, and "End
Sessions" clears the recorded pane ids so reopening spawns fresh shells
with `--resume` rather than trying to re-attach to panes that are already
dead.
Separately, a restart could materialize one *local* workspace's tabs
inside a different workspace's window and auto-resume every one of their
agents a second time — real, duplicate `claude --resume` calls against
conversations already running elsewhere. The root corruption that seeded
a workspace's records with another one's panes is still unattributed, but
every way it could propagate or amplify is now closed: `Spawn` carries
its owning workspace so restore refuses to attach a pane owned by another
one; pane ids are bound to the daemon instance that issued them, so an id
recorded against a dead process can't attach to an unrelated shell after a
reboot (agent resume still happens on this path, since the pane really is
gone); deduping two claims on one pane id now strips the loser's agent
session id and launch flags along with the id, so a corrupted record can't
double-resume an agent the winner already runs; and saving a session now
logs an error naming both workspace ids if a window is ever caught holding
a pane it doesn't own, so a recurrence is caught rather than silently
re-amplified. All wire and schema changes are backward and forward
compatible. (#257)
- **A captured pane reads like text, not a stream of raw escape codes, and a
broken pipe ends quietly** — `tty7 capture --plain` replays a pane's bytes
through the same `alacritty_terminal` grid the window renders panes with,
instead of leaving a script to regex-strip escape sequences itself.
Stripping alone gets the easy 90% and invents the rest: whether 200
characters with no trailing newline in a 249-column pane is one logical
line or a soft wrap is a fact about the terminal grid, not about the
bytes, and the same is true of a `\r`-overwritten progress bar, a
redrawing shell prompt, and cursor-addressed text. `--plain` now replays
each streamed segment at the width the daemon actually recorded it with —
panes on different machines are different widths, and a hardcoded
assumption would rewrap both wrong — so a captured pane matches what the
window would have shown. Raw `capture` (no `--plain`) is unchanged: still
the exact bytes the daemon stored.
Separately, every CLI verb that outputs to a pipe now ends the way `cat`
does when the reader hangs up — silently, with the shell's own SIGPIPE
exit code — instead of a Rust panic and a backtrace on stderr, which most
verbs produced on something as ordinary as `tty7 ls | head -1`. Rust
disables `SIGPIPE` by default, so a doomed write surfaces as an
`io::Error` and `println!` turns that into a panic; the fix restores the
default disposition on Unix so the kernel handles it directly, and
recognizes the Windows equivalent (`ERROR_NO_DATA`) at every stdout write
site since Windows has no signal to hand back to. (#283)
- **Restarting the server no longer strands the window on the home page** —
after confirming "Restart Server?", the window's post-restart resync could
go out on the control link to the server that had just been killed: the
connected flag is an `AtomicBool` the reader thread only flips on EOF, so
right after `restart()` returns the link often still read as up, the
layout pull failed against a dead socket, and the failure was logged and
discarded rather than retried — leaving the window sitting on an empty
home page with no hint anything was owed to it. A failed hydration is now
recorded as debt and retried on the next sync, and a window that hasn't
yet recovered its layout no longer pushes its own (empty) state back to
the server in the meantime — previously that could diff into "close every
tab" and erase the layout on disk for a window that was only ever waiting
its turn. (#282)
- **Tab no longer clears your command line when a remote pane's link is
down** — `Tab` reaches the prompt editor through a `SendTab` action that
bypassed the same-input guard ordinary typing goes through, so on a
reconnecting remote workspace a completion with no candidates handed the
line off to a dead link anyway: the command vanished, the editor stood
down for the rest of the prompt cycle, and every keystroke after it —
Enter included — was swallowed by the very guard the Tab had skipped. The
guard now covers the Tab path too, so the line stays put and typing
resumes once the link is back; a late-arriving SFTP completion result is
also dropped, with the reason logged, rather than acting on a line the
editor no longer owns. A local pane, which has no notion of a down link,
is unaffected. (#273)
- **The IME candidate window follows the actual caret, not a parked
cursor** — typing Chinese in a Kimi CLI pane on Windows stranded the
candidate list at the input box's bottom-right corner instead of
following it. Two independent causes stacked: gpui never answered
`WM_IME_REQUEST` / `IMR_QUERYCHARPOSITION`, which is how Windows 11's
default Microsoft Pinyin IME asks for caret position — it ignores the
older `CANDIDATEFORM` call tty7 was already making once per composition —
so an unanswered query fell back to the IME's own default spot; and Kimi
CLI itself hides the hardware cursor and draws its own caret as a
reverse-video cell that never gets parked at the logical input point,
landing the only real anchor tty7 had at the right edge of the input box's
border. The fix answers the position query from the input handler and
re-anchors on every composition update instead of sampling once, and when
the hardware cursor is hidden and its row holds exactly one caret-sized
(≤2 cell) inverse run, that run is now read as the IME anchor. Apps that
show the real cursor (pwsh, vim, current Claude Code) are unaffected; the
heuristic only applies with the cursor hidden, where the previous anchor
was arbitrary anyway. (#284)
- **A link that wraps across rows opens the whole URL, not just the clicked
line** — ⌘-click and ⌘-hover resolved a URL only from the physical grid
row under the pointer, so a soft-wrapped URL opened its first line only
and 404'd. OSC 8 hyperlinks now resolve across a soft wrap as one
contiguous run, using the link's own declared URI rather than
reconstructing it from the clicked row; bare URLs and file paths resolve
through the existing soft-wrap stitching, plus a new opt-in mode that also
bridges a program's own *hard* newline when the row is full to the right
edge and the last character continues the link into the next row's first
column — so an ordinary short line is never glued to the next paragraph.
Double-click word/smart-select is unaffected; only link click and hover
bridge hard wraps. (by @ayamir in #258)
- **A workspace title stays tied to the workspace** — foreground process and
agent names no longer replace the workspace title in the sidebar or its
switcher button. An explicit workspace name wins; otherwise the title is
derived from the workspace's repo/cwd, with `Untitled` as the final fallback.
- **Fullwidth CJK punctuation no longer overlaps, and prompt-mark scanning
is faster** — wide glyphs are now shaped independently instead of being
batched together, which was producing the overlap; OSC prompt-mark
scanning moves from a byte-by-byte scan to SIMD-backed `memchr`; and Thai
and Lao SARA AM combining-mark clustering is reconciled with upstream.
(by @ChihGodlee in #250)
- **tty7 gets a taskbar icon and identity on Linux** — the window now sets
`app_id: "tty7"` on every platform, which X11 uses as `WM_CLASS` and
Wayland matches against the packaged `tty7.desktop` entry, so taskbars and
window switchers can resolve the app's identity and icon where they
previously couldn't. X11 additionally needs the icon on the window itself
(`_NET_WM_ICON` ships raw pixels per window rather than reading the
desktop entry), so `assets/app-icon.png` is decoded once and downscaled to
256px — the most a taskbar uses — and attached via `WindowOptions::icon`.
macOS and Windows already got their icons through the bundle and the
embedded `.ico` respectively and are unaffected. (by @zerolover in #254)
- **Installing a remote server keys off the wire dialect it actually
speaks, not its version string** — two builds between releases can share a
version string but not a dialect, which previously let a client adopt an
incompatible remote server and fail permanently in the handshake instead
of reinstalling. (#264)
- **Listing WSL distributions no longer hangs the shell menu** — a
starting-up, updating, or wedged `wsl.exe` is now given a bounded wait
before the probe reports no distributions, matching what an unreachable
WSL already produced instead of holding the whole menu open.
- **⌃R on a remote machine searches that machine's history** — tty7 owns ⌃R at
the prompt and shows its own fuzzy menu, but the store behind it had no notion
of *where* a command had run. Every pane read one file, so ssh'ing to a server
and reaching for ⌃R offered the commands you had typed on your laptop —
worse than offering nothing, since the answers look plausible until you run
one. History is now kept per machine: the local store stays where it was, and
each remote gets its own, keyed by the target you connected to. On a remote
workspace tty7 also reads the far end's own `~/.zsh_history` and
`~/.bash_history` through the same channel it already uses for git and file
listings, so the first ⌃R on a freshly connected box has something in it
rather than starting empty. Switching a pane between machines swaps the store
under it, and the local history file is untouched by the upgrade. (#270)
- **A Windows clipboard pastes like every other clipboard** — text copied on
Windows carries `\r\n`, and a bracketed paste forwarded it byte for byte. vim
counts CR and LF as two line breaks, so pasting a block of code into it left a
blank line under every line — bad enough to make tty7 unusable for editing.
Bracketed pastes now fold `\r\n` down to a single `\n`, which is exactly what
the same paste already produced on Linux and macOS. The non-bracketed path is
untouched: with no paste mode to distinguish text from typing, a line break
still has to arrive as the CR the Return key sends. (#270)
- **Closing every window before quitting no longer loses your place** — launch
only ever restored a workspace that still had a window at quit, so closing them
one by one and relaunching came up on the empty home page, with no hint that
four workspaces were sitting there. Closing a window here is a *detach*: its
panes keep running in the daemon, which makes that workspace every bit as much
"where you left off" as one that still had a window on screen. Launch now falls
back to the workspace closed last, and the only launch that comes up on a fresh
workspace is a genuine first run. Deleting a workspace still means deleting it —
that is the one gesture that drops it from the file. (#267)
- **An unsubscribed directory watch stops delivering immediately** — dropping a
local watch handle now closes its delivery channel rather than only asking the
OS backend to stand down. Tearing that backend down is not instantaneous, and
on Windows a `ReadDirectoryChangesW` completion can fire *during* teardown and
reach a consumer that has already unsubscribed. Batches already queued stay
readable, which is the one thing a consumer racing its own drop may
legitimately still see. (#247)
- **Rounded UI controls no longer square off their corners**
([#236](https://github.com/l0ng-ai/tty7/issues/236)) — the cursor-shape
toggles (Block / Bar / Underline) are the clearest case: the selected
segment's fill filled the whole corner of the track it caps, with the track's
own anti-aliased border arc floating *inside* that square. The controls were
relying on `overflow_hidden` to shape those fills to the track's rounding, and
it cannot: gpui's overflow mask is an axis-aligned rectangle with no corner
radii, tested per fragment as a hard discard, so it can only ever cut a square
and never anti-aliases the cut. A container's own corners come from the quad
shader's distance field instead, which is why a plain card looked smooth while
anything with a filled child in the corner did not. Every such fill now
carries its own radius, inset one border-width so it nests inside the border
rather than bulging past it: the segmented controls, the −/value/+ steppers'
hover fills, the theme picker's flush previews, and the diff overlay's card
headers and closing rows. Most visible at a device pixel ratio of 1, where the
hard clip edge is a whole physical pixel wide. (#244)
- **Dragging the window by a title bar worked only rarely with a trackpad** —
five rows that stand in for the caption (the tab rail's top zone, the settings
page's top strip, the detail panel's top zone, and the code and diff overlays'
headers) armed their window-drag with a flag that was rebuilt on every render.
Any repaint between the press and the first drag event threw the arm away, and
the press *itself* schedules one — these rows carry a double-click, which makes
gpui refresh the window on mouse-down — so the drag only survived if the first
move beat the next vsync: 16ms at 60Hz, 8ms on ProMotion. A mouse press nudges
the pointer and often won that race; a trackpad press is a finger pushing down
without translating, and almost never did. The terminal's cursor blink disarmed
it on its own even without a press. The arm now lives in element state, which
survives frames — where gpui-component's own `TitleBar` has always kept it,
which is why the ordinary caption strip was never affected. (#252)
- **An idle window stays idle while a file in the Files panel is being written**
— the tree watches its roots plus every directory you have expanded, so what
it hears about is a change in a directory it is *displaying*, and a file's
contents being rewritten reports exactly as loudly as a file appearing. A
formatter rewriting in place, an editor saving on every keystroke, a build
dropping its log next to the sources: each of those repainted the whole
window, twice over, for as long as it went on. A window with nothing new to
draw never reached render idle, which is what issue #243 reports in its title.
A batch now repaints only when the re-read comes back different from what is
already on screen, and that re-read is issued from the watcher callback rather
than by asking for a paint in order to get one. A closed Files panel does no
work at all: the change is recorded and picked up on reopening. A panel showing
search hits is the same case — the hits are their own walk, so no listing is on
screen to refresh — and the change is picked up when you clear the search box.
Real changes still arrive exactly as fast.
Measured headlessly by counting window draws, before and after: five rewrites
of a file in a displayed directory cost 10 frames and now cost 0.
`.gitignore` edits keep their whole-cache refresh, now taken only when the file
can actually govern a directory the tree holds. That one is a correctness guard
on the most expensive branch here rather than a measurable saving — the watch
is non-recursive, so a `.gitignore` has to sit directly in a displayed
directory to arrive in the first place.
The other half of that report — the Files panel flickering — was fixed
independently by "keep file-tree listings on screen while they refresh", which
landed first and is the mechanism the panel now uses. This is only the frames.
Note the redraw behaviour is platform-independent and is what that number is
about; the reporter's ~15% CPU figure, and whether their flicker also
involved something in the Wayland presentation path, were not reproducible on
macOS and are not claimed to be confirmed. (#249)
- **Return no longer confirms the file tree's delete prompt** — it was the only
destructive prompt in tty7 with the destructive action first, and on macOS
(NSAlert) and Windows (TaskDialog) the first button is the Return-key
default, so pressing Return deleted — recursive folder deletion included. The
buttons now put the safe option first, matching every other destructive
prompt, with Escape still cancelling. Linux uses gpui's click-only fallback
dialog, so the swap only reorders the buttons there. (#255)
- **"Finder" is no longer named on Linux and Windows** — the file-tree context
menu and the SFTP job list's reveal tooltip hardcoded Finder-flavoured
labels on every platform; only the Info row's button was conditional. All
three sites now share that one conditional, so the action reads "Reveal in
Finder" on macOS and "Open Folder" everywhere else. On macOS the SFTP
tooltip's "Show in Finder" becomes "Reveal in Finder" too, retiring a third
name for the same action. (#255)
- **Grok Build turns up in settings search** — the agent renders a Settings →
Agents row but had no search-index entry, so searching could never surface
it. The other five agent entries had drifted from what their rows actually
say ("Claude Code hooks" for a row titled "Claude Code"), as had "Option
acts as Meta" from the rendered "Option (⌥) acts as Meta"; the index titles
now match the rows, the mechanism words (hooks / plugin / extension) stay
behind as search keywords, and a test derives the Agents index from the
agent list so a future agent can't ship unsearchable. (#255)
## [26.7.6] - 2026-07-28
### Added
- **Readline parity at the prompt** — keys that worked in a raw terminal stopped
working the moment shell integration engaged, because the local command editor
owned the keyboard at the prompt and swallowed every chord it didn't
recognise. `Ctrl-P` / `Ctrl-N` now walk history exactly like ↑ / ↓ (completion
picker and reverse search included); `Ctrl-Y` yanks back whatever `Ctrl-W` /
`Ctrl-U` / `Ctrl-K` / `Alt-D` last cut, from a one-slot kill ring of the
editor's own — zle's ring holds text this editor never cut, so borrowing it
would paste the wrong thing; and `Alt-.` inserts the previous command's last
word, repeating to walk further back. Any chord the editor has no answer for
— `Ctrl-T`, `Alt-U`, a `bindkey`-ed widget — now hands the line to the shell
so zle's keymap decides, instead of vanishing. Pressing any of them while
scrolled up snaps the viewport back to the live prompt first, so a recalled
line can't be edited off-screen. (#222)
- **One Dark Pro built-in theme** — a ninth built-in, slotted alphabetically
among the dark themes. Two deliberate departures from the VS Code theme's
terminal set, both caught by this repo's contrast tests: the accent is the
editor's focus blue `#528bff` rather than the syntax blue, which sits at the
same luminance as the switch knob it has to be legible against; and normal red
stays the classic `#e06c75`, because the Pro terminal red lands too close to
the warning orange to stay distinguishable. (#224)
- **Panes are told which terminal they're running in** — every pane now carries
`TERM_PROGRAM=tty7` and `TERM_PROGRAM_VERSION`, the de-facto standard pair
Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all
set. `TERM` names terminfo capabilities and can't answer "which program is
this", so without the pair, capability probes (`supports-color`,
`supports-hyperlinks`, and the CLI ecosystem built on them), editors applying
terminal-specific workarounds, and shell prompts all fell back to their most
conservative behaviour. tty7's own `TTY7` marker doesn't help them — it exists
so globally-installed agent hooks stay silent in other terminals, and nothing
third-party knows to look for it. Unlike `TERM` and `COLORTERM`, both new
variables can be overridden from `env` in `config.json`: they name an
identity, not a capability, and posing as another terminal is a legitimate way
to get a tool that only recognises a fixed list to light up. Local panes only
— ssh forwards environment variables solely by agreement between client and
server, so a remote host still sees whatever it sets for itself. (#219)
- **Inactive panes only fade if you want them to** — a split tab dims every pane
but the focused one so the active terminal reads as foreground. That is the
right default, but it is not free: at 55% opacity a dim theme's comment color
or a long-running build's output in the pane you are *watching* rather than
typing into gets harder to read, and some people track panes by cursor alone
and never needed the cue. Settings → Appearance → Transparency now carries a
"Dim inactive panes" switch. On by default, so nothing changes for anyone who
was happy; off renders every pane at full opacity. (#214)
### Changed
- **One fewer duplicate SVG stack in the build** — the resvg 0.47 bump (#227)
left the gpui fork on 0.45, so the tree compiled two resvg/usvg/tiny-skia
stacks. The fork now pins 0.47 too, re-unifying its stack with the one tty7
uses for the tray icon. (#237)
- **The last duplicate SVG stack is gone** — after #227 and #237 unified tty7
and the gpui fork on resvg 0.47, the gpui-component fork still declared its
own `resvg = "0.45.1"`, keeping a legacy resvg/usvg/tiny-skia 0.45/0.11
stack in the tree. That fork now pins 0.47 as well, so the whole build
compiles a single resvg stack. (#238)
### Fixed
- **Box-drawing characters no longer break into dashes between rows** — they
rendered as font glyphs, and a glyph only covers the font's own line height
while the cell is `font_size × line_height` (1.4 by default). At any line
height above 1.0 every vertical run of `│` `╭` `╰` was perforated at each row
boundary: two-line prompts never closed their corners, TUI frames were dotted
down both sides. U+2500–U+257F and U+2580–U+259F are now drawn as native
geometry pinned to the cell's real edges — the same special case kitty,
Alacritty, WezTerm and iTerm2 all ship, and what tty7's Powerline separators
already did. Covers mixed light/heavy weights, the double-line set with its
junctions left open, rounded corners, dashes, diagonals, block eighths and the
░▒▓ shades. (#229)
- **Underlines survived box-drawing characters, and strokes stay one weight at
fractional DPI** — two things the native-drawing path changed without meaning
to, both invisible at the integer device scale it was developed on. An `ESC[4m`
span or a hovered URL showed a one-column hole wherever it crossed a box
character, because the underline rides the shaped text run and the native cell
returned before building one; the cell now shapes a space in its own style, so
gpui draws the line from the same `UnderlineStyle` as its neighbours. And at
125% / 150% / 175% display scaling a vertical rule alternated thin/thick across
every column, since a logical stroke width that isn't a whole number of device
pixels rounds differently per column; widths are now laid off the snapped near
edge in whole device pixels. Nothing changes at 1x or 2x. (#234)
- **Thai SARA AM lost its vowel and rendered as a dotted circle** — `ำ` (U+0E33)
is width 1 and correctly gets its own column, but it is not atomic to the
shaper: rustybuzz decomposes it and moves the nikhahit backwards onto the base
consonant, which needs the base in the same run. It was being segmented alone,
so there was nothing to reorder onto. A following SARA AM is now absorbed into
the preceding cell's cluster, so base, tone mark and vowel shape together. Lao
SARA AM (U+0EB3) takes the same path and comes along. (#230)
- **Italic CJK rendered as unrelated CJK on Windows** — every character came out
as a different character, one for one, consistently, so it read as a broken
locale or a mangled encoding. It was neither. Hack, the bundled default, has no
CJK, so those cells are shaped by the font-fallback chain; gpui's Windows
backend then threw away the face DirectWrite shaped with and looked a fresh one
up by family, weight and style. That round trip mapped DirectWrite's *italic*
to *oblique* — the two are numbered the other way around in the API — and a
family with no oblique face resolved to its upright one. The glyph indices were
right; the outlines they were pointing into belonged to a different face. Fixed
in our gpui fork by rasterizing the face DirectWrite actually chose, 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. (#233)
## [26.7.5] - 2026-07-27
### Added
- **Tab completes remote paths in an SSH pane** — path candidates came off the
local filesystem, so a native-SSH pane deliberately passed no cwd and Tab
found nothing; a no-match hands the line to the shell, which costs the inline
editor until the next prompt. Since paths are what people complete most, in
practice every path Tab in an SSH session dropped the user back to the raw
shell. Tab now lists the remote directory over the pane's own authenticated
connection — the same request the SFTP panel browses with — so nothing is
echoed into the scrollback and no prompt hooks are involved. `cd` still filters
to directories. Command position and `~/` still fall through, as does a
foreground `ssh` typed into a local shell or a WSL pane, neither of which has a
tty7-owned connection to ask. (#217)
- **The last-window close confirmation can be turned off** — Settings → Window &
Tabs gains **Confirm before closing the last window**, on by default. The
prompt is a teaching device, not a safety net: ⌘Q, the tray's Quit and the
palette all quit without asking, and nothing is lost either way since the panes
keep running in the daemon. Once that model is learned, an extra dialog on
every quit is friction. Brings the window-close prompt in line with the SSH
close warning, which has had a toggle all along. (#206)
- **The detail panel and tab rail have a scrollbar** — Info / Outline / Changes,
the file tree, the remote SFTP listing and the tab rail all scrolled with a
container that painted nothing, so a deep tree or a long tab list gave no hint
that there was more content or where in it you were. The bar takes its colours
from the active theme rather than a stock grey, and follows the platform:
auto-hiding on macOS, permanently visible on Windows and Linux. (#193)
- **Foreground applications can negotiate the Kitty keyboard protocol** — the
embedded terminal config inherited `kitty_keyboard: false`, so the parser
ignored the negotiation sequences and an application asking for progressive
enhancement fell back to `modifyOtherKeys`, which tty7 doesn't implement. That
collapsed distinct chords onto the same legacy byte — `Shift+Enter` reached the
application as a plain carriage return, submitting a prompt instead of
inserting a soft newline. Legacy input is unchanged until an application opts
in. (#184)
- **The window's leading corner carries the app's mark off macOS** — macOS fills
the top-left with the traffic lights; on Windows and Linux that corner was
empty, with everything the caption row holds (the rail's "+" and collapse, the
corner chrome, the window controls) pushed to a right edge. The "duo" mark now
heads the tab rail on its 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 is collapsed — so the corner never falls back to nothing.
Drawn, never clicked: it takes no hover capsule and no hit box, leaving the
strip grabbable through it. (#202)
### Changed
- **Interaction state and status colours are derived from the active theme**
([#197](https://github.com/l0ng-ai/tty7/issues/197)) — a segmented control's
selected option was indistinguishable from its neighbours on *every* bundled
theme (Dracula worst at 1.03:1), because the theme had a colour model but no
state model: fixed blend ratios scattered across the code, plus every
gpui-component field nobody had noticed still carrying stock greys and Tailwind
hues. Each painting surface now carries a state ladder derived to hit a
contrast *ratio* against that surface, so the result no longer drifts with the
theme — selected-vs-resting goes from 1.20–1.47:1 to 1.70–1.72:1, anchored so
Dracula's already-signed-off highlight is a no-op. Ladders are per surface, so
a menu row anchors to the popover it actually sits on. Taking a fill now also
takes its paired label colour, instead of each site hand-fixing that
separately. `danger`/`warning`/`success`/`info`/`link` come from the theme's
own ANSI-16 at 33 call sites — Dracula's delete button is literally the
`#ff5555` the terminal in the same window paints with. Switches were inverted
on every dark theme (a near-black knob on a light track, invisible on the dark
one) and now take the light end of the theme's axis with the accent on the
checked track. (#205)
- **The bundled icon set is redrawn to one humanist spec** — the previous set
accumulated one exception at a time: a heavier `plus` because a bare cross
looked frail, a `stock/` prefix to undo overrides that were too heavy at 16px,
two names sharing one drawing. All 17 glyphs now hold to a single spec — 2.1
stroke throughout, corner radii matching the app's 10px panels, near-square
boxes of equal apparent area, round caps and joins everywhere. Metaphors are
untouched, so nothing needs relearning; `folder-closed` gains an inner rule
that distinguishes it from `folder`, and `info` a title bar that stops it
colliding with `panel-left`. (#190)
- **The title bar spans the detail panel off macOS** — the panel was a
full-height column *beside* the bar, and the bar lays out ─ ▢ ✕ at its own
right end, so opening the panel on Windows or Linux stranded the window
controls mid-window with the panel's grey to their right. Layout moves to
`[rail | col(bar / row(body, panel))]` so the bar reaches the corner, with the
caption row over the panel painted in the panel's own surface — the column
reads as one continuous sidebar from the very top instead of starting 40px down
in a different colour. The panel's tab tiles move into the section header it
was already drawing, since a tile row of its own made three stacked headers
before a single line of content. macOS is untouched. (#188)
### Fixed
- **Splitting or un-maximizing a pane you were hovering no longer kills the app**
([#201](https://github.com/l0ng-ai/tty7/issues/201)) — a pane remembers the
cell under the pointer (that's what makes ⌘-hover underline links), and nothing
invalidated it when the grid shrank underneath. The remembered row then named a
line the grid no longer had, and the next modifier press indexed the grid with
it — inside a gpui input callback, which is `extern "C"` and cannot unwind, so
the process aborted with the message lost. `⌘⇧D`, `⌘⇧⏎` and dragging the window
smaller were all reliable ways to hit it. The hovered cell is dropped on resize
and the row is validated before indexing. Two other aborts went with it: `⌘T` /
`⌘D` against a dead daemon now restarts it and retries once, and session
restore with an unreachable daemon restores what it can instead of dying. A
panic anywhere in the GUI now also lands in `<config-dir>/crash.log` with its
message, location and backtrace — the OS report only ever kept the abort.
(#204)
- **Emoji written with a variation selector take their real width and
presentation** ([#203](https://github.com/l0ng-ai/tty7/issues/203)) — `🗂️`,
`❤️` and `⚠️` were budgeted one column, so the glyph bled over its neighbour
and every following cell on the line shifted left by one, taking selection and
click hit-testing with it. The selector is zero-width and lands *after* the
base's column budget is spent, and nothing revisited the decision. The same
gap made `❤️` render as the black text-presentation heart, identical to bare
`❤`, because only `cell.c` reached the shaper — dropping every combining mark,
not just variation selectors. The terminal now re-scores the sequence and
widens the cell, and carries the marks through to be shaped with their base:
`❤️` and `⚠️` come up in colour while their bare forms stay monochrome, and
`e` + U+0301 renders as `é`. Narrowing (`U+FE0E` on an already-wide emoji) is
still not implemented — it would have to free a column and reflow the line.
(#210, #216)
- **Untrusted terminal output can no longer freeze a pane** — with Kitty
keyboard negotiation enabled, an upstream `alacritty_terminal` bug became
reachable: `push_keyboard_mode` caps its stack by removing from the *title*
stack, a copy-paste slip that compiles because both are `Vec`s. With an empty
title stack that panics and kills the reader thread, freezing the pane; with a
non-empty one it silently drops a saved title, so a later restore returns the
wrong one. It doesn't take hostile output — a TUI that pushes without popping
reaches the 4096 cap on its own in a long session, and `cat` of a crafted file
or a remote host over SSH gets there in one ~20KB burst. Pinned to a patched
fork, with a regression test that guards the pin. (#194)
- **`theme_follow_system` no longer panics every launch on Linux** — flipping
"sync with system" on in Settings persists immediately, so the app then aborted
on every start until `config.json` was hand-edited back, with a backtrace
pointing at gpui internals and nothing connecting it to the theme toggle. gpui's
Wayland and X11 backends dispatch the appearance-changed callback while the
platform client's `RefCell` is already mutably borrowed, and reading
`cx.window_appearance()` from that callback re-borrows the same cell. The OS
appearance is now cached in a global that the observer fills from the window's
own cell, so nothing on the re-entrant path touches the client. macOS and
Windows were never affected. (#181)
- **A config file saved with a UTF-8 BOM no longer wipes your settings** — every
config-dir file is read by a loader that treats any parse error as "there is
no file", falling back to defaults. `serde_json` rejects the U+FEFF a BOM puts
before the opening brace, so a BOM didn't report a broken config — it reported
an absent one, and tty7 came up on defaults with nothing in the log to explain
it. Windows makes this 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. `config.json`, `session.json`
(which lost every workspace the same way) and hand-authored `themes/*.yaml`
now skip a leading BOM. (#215)
- **New tabs and splits open in the right directory even when the shell can't be
instrumented** ([#187](https://github.com/l0ng-ai/tty7/issues/187)) — a pane
learned its directory from `OSC 7`, which only shells tty7 injects its
integration into ever emit. A shell that `exec`s 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 emitted none — and because a pane's
directory is seeded with the one it was spawned in, such a pane didn't report
*no* directory, it reported a permanently stale one. New tabs, splits, the git
probe and path completion all followed it to the wrong place, with nothing on
screen to say why. The pane now also reads its directory from the process
table, on the same half-second foreground poll that already detects SSH
sessions and coding agents. A shell that does emit `OSC 7` keeps its own
spelling of the path: `$PWD` preserves the symlinked route the user walked in
through, and that is the one a new tab should open in. macOS and Linux;
Windows has no equivalent process query and is unchanged. (#207)
- **Links open on Ctrl+click on Windows and Linux**
([#183](https://github.com/l0ng-ai/tty7/issues/183)) — the link modifier was
the platform key, which gpui maps to ⌘ on macOS but to Win/Super elsewhere, a
key the OS mostly swallows. Off macOS neither the hover underline nor
click-to-open could be triggered at all, and the only way to follow a URL was
to select and copy it — while `config.json`'s own docs had promised
"⌘/Ctrl-click" all along. Now the same portable secondary modifier the
keybindings already use, so ⌃-click keeps meaning right-click on macOS.
Settings copy and the docs follow the platform. (#192)
- **Option-as-Meta works with a CJK input source**
([#177](https://github.com/l0ng-ai/tty7/issues/177)) — macOS gives Option two
jobs, and routes the chord before the terminal sees it. With a non-ASCII input
source active, ⌥F went to the IME, which committed `ƒ` and consumed the event,
so the code that turns the chord into `ESC f` never got a say. The setting
worked on ASCII layouts and silently did nothing on CJK ones, which is why it
read as intermittent rather than broken. gpui can now decide per keystroke
rather than once per view, so Meta chords are claimed by the terminal while
ordinary text, dead keys and Pinyin composition still reach the IME. gpui
accordingly moves to the `l0ng-ai/zed` fork. (#191)
- **A whole command cycle arriving in one read reports both prompt states** —
the OSC sniffer folded a chunk's shell-integration marks into one state and
sent a single frame, hiding the prompt boundary the client counts to tell a
fresh prompt from a same-prompt redraw. Routine over SSH, where a fast
command's start mark, output and completion mark leave the remote in one
packet. Marks on the same side of a boundary still fold, so an ordinary prompt
draw costs one frame as before. (#217)
- **The detail-panel toggle stops drawing itself as selected, and the Files
dotfile switch moves into the tree's right-click menu** — follow-ups to the
caption-row rework, which left the toggle permanently lit and the dotfile
control competing for room on the header line. (#188)
- **The editor and diff overlays keep their header on the caption line when the
detail panel is open** — off macOS the title bar is hoisted above
`[terminal | panel]` so the ─ ▢ ✕ group can reach the window's corner, which
left both overlays — anchored to the terminal column — starting 40px down.
Their headers are drawn to *be* the title bar while they're up (its height,
its insets, a full chrome tile for their one control), and instead landed a
row low, level with the panel's tab row. They now hang on the row that owns
the bar, inset by the panel's width, so the corner chrome keeps its surface
and its clicks. (#202)
- **Those headers became real title bars** — dragging one now moves the window
and double-clicking zooms it. Both covered the caption row and neither did
either, with the panel open or closed, so opening a file turned the top of the
window into a 40px strip that looked exactly like a title bar and answered
nothing. Their controls (the ✕, the diff's back-to-all-files chip) are
`occlude()`d to keep taking clicks: a drag region on Windows is HTCAPTION, and
the OS claims the press before the app hit-tests. Double-clicking a stand-in
title bar zooms the window on Linux too. (#202)
- **The rail's top zone lines up with the title bar to the pixel** — the bar
reserves a hairline inside its own height that the rail's stand-in row didn't,
so everything in that row sat half a pixel low. Invisible on the line-art
tiles; not on the mark, which visibly hopped as collapsing the rail handed it
over to the bar. (#202)
- **CJK and emoji stop falling through to the OS on Windows and Linux** — the
default `font_fallbacks` named only faces that ship with macOS (Menlo, Apple
Color Emoji), so off macOS the entire chain matched nothing and every
ideograph and pictograph was resolved by the platform's own cascade instead.
The bundled Hack primary carries no CJK at all, so on Windows this was every
Chinese character in every pane. Defaults are now chosen per platform —
PingFang SC / Apple Color Emoji on macOS, Microsoft YaHei / Segoe UI Emoji on
Windows, Noto on Linux — and those stock names are appended to a
hand-written list as well, so a `config.json` that predates this change (or
was copied from another machine) is repaired at use time without being
rewritten. Maple Mono NF CN stays first on every platform: its 1.2em CJK
advance is the only exact fit for the two-column slot Hack's 0.60205em cell
produces, so a 1.0em stock face is left-aligned there with the remaining
~0.2em showing as a gap on the right of each character. (#195)
## [26.7.4] - 2026-07-26
### Added
- **Grok Build gets the full hook integration** — Settings → Agents grows a
sixth row, installing tty7's hooks to `~/.grok/hooks/tty7.json`. A grok pane
now carries the same live status the other agents do (blue "working" → green
"done", amber "needs you" when grok asks a question), and after a restart it
relaunches `grok --resume <id>` with the original launch flags instead of a
bare shell. Panes that only ever had the Claude Code hooks installed are
relabeled to grok rather than reporting as "Claude Code", and grok's brand
mark replaces the generic robot avatar. (#174)
- **Edit, File and Help menus** — copy/cut/paste/select-all, undo/redo and
find/find-next now live in a real Edit menu; `About tty7`, `Check for
Updates…`, `Hide tty7` and Services sit in the app menu; Docs, Discord and
`Report an Issue` are reachable from Help; and tab actions that used to exist
only in a context menu (Rename Tab, New Worktree Tab, Close Other Tabs, Close
Tabs to the Right, Copy Working Directory) are real actions you can also find
in the palette and rebind in Settings. (#175)
### Changed
- **The menu bar follows the HIG** — `tty7 · File · Edit · View · Window ·
Help` replaces `tty7 · Shell · Window · View`. The `Shell` menu is gone: its
new / close / split / rename items are File's job everywhere else, and the
name collided with Settings → Shell, which configures something entirely
different. View gained the layout toggles and pane commands that were
chord-only, and `Delete Workspace…` moved behind its own rule at the bottom
of File. (#175)
- **The command palette is ranked, banded, and says what it will do** — results
are scored rather than filtered, so word-initials and prefixes outrank
scattered letters; the idle list opens on `Recent` (frecency) followed by
Tabs & Panes · Workspaces · View · Terminal · SSH · Agents · Application
instead of 47 flat rows. Names describe the outcome and the current state
(`Hide Left Sidebar`, `Tab Bar: Move to Left Sidebar`) rather than the switch,
one namespace prefix per subsystem, and the naming grammar is written down at
the top of `palette.rs`. (#175)
- **Settings re-sectioned around what you're configuring** — the orphan `Shell`
page folds into Terminal's first group, a new `Input` page collects the prompt
editor, selection/clipboard and keyboard settings, Terminal drops from seven
groups to four, and notifications moved to Window & Tabs where the rest of the
app-level behaviour lives. The search index grew from 14 entries to 51 with
titles pinned to the rendered row labels, so `opacity`, `blur`, `completion`,
`ctrl-r` and `grouping` now find their rows. (#175)
- **The tab bar defaults to the left sidebar** — a fresh install opens with the
vertical tab rail instead of the horizontal title-bar strip. Anyone who has
flipped the setting or hit `ToggleTabSidebar` has an explicit value persisted
and keeps their layout. (#171)
- **Idle chrome tiles paint at the rail's ink weight** — title-bar toggles,
panel tabs, `+` and their siblings now draw their glyph in
`sidebar_foreground` instead of the near-black `foreground`, with full
strength reserved for the selected tile. The chrome no longer reads a step
darker than everything it neighbours, and "on" gets a second cue beyond the
grey capsule. (#176)
### Fixed
- **Non-ASCII output survives a Finder/Dock launch** — macOS GUI launches can
omit every locale variable, leaving the shell in the C locale: `ls` printed
one `?` per non-ASCII byte, so a CJK filename came out as `??????`, and tmux
replaced each Unicode cell with `_`. New shells now receive a UTF-8 character
locale (`LC_CTYPE` only, so message/date/number localization is untouched)
derived from the system locale the way Terminal.app does it, and only when no
inherited or configured locale exists — explicit locale choices still win.
The derived name is checked against the locales actually installed before it
is exported, so it stays loadable on remote hosts too, where ssh forwards
`LC_*` by default.
([#178](https://github.com/l0ng-ai/tty7/issues/178), #173, #180)
- **Right-click Paste dropped images, and Copy looked disabled** — copy, cut,
paste and undo each had two implementations, a chord handler and an action
handler, which had drifted: the context menu's Paste skipped the image branch
⌘V had, so pasting a screenshot into an agent pane worked by keyboard but not
by menu, and Copy rendered disabled whenever the selection lived in the prompt
editor rather than the terminal grid. Both now route through one method each.
(#175)
- **The palette named the other window's sidebar** — the stateful palette titles
read the config copies of `sidebar_collapsed` / `right_panel_visible`, which
only record whichever window toggled them last. With two windows in different
states, one window's palette offered "Show Left Sidebar" while its rail was
already out. Each window now labels the toggles from its own chrome state.
(#175)
- **No more update prompts for a half-built release** — the release step ran
inside the build matrix, so the first platform to finish published a release
carrying only its own assets, and that release became `/releases/latest`
immediately. The in-app update check polls exactly that endpoint, so users
were prompted to download a version whose `.dmg` was still notarizing. All
four platforms now upload artifacts and a single gated job assembles one
**draft** release with every asset. (#172)
## [26.7.3] - 2026-07-25
### Added
- **Multiple windows, one workspace each** — `New Workspace` (⌘⇧N) opens a
second window with its own tabs, splits, and chrome state. A workspace is
the persistent thing: closing its window puts it away with its panes still
running in the daemon, and the title bar's workspace menu, the command
palette, and the home page's picker all bring it back. `Stop Workspace`
ends one for real (no default chord — it kills sessions), `Delete
Workspace` also forgets the layout, and a workspace can be renamed from
the title-bar chip. (#169)
- **Workspace switcher, in the two places you'd look** — a title-bar chip
(monogram + chevron) whose menu lists every workspace with a monogram badge
and a corner dot for the ones whose shells are still running, and the macOS
**Window** menu listing the same set: on screen first, then the detached ones
with how long ago you left them. Both show the first nine. (#169)
- **Detail panel** — a docked right-hand column that shows what the active
pane *is* rather than what it's printing: **Info** (cwd, branch, shell,
agent, the process tree and the TCP ports the pane is listening on),
**Outline**, **Changes**, and **Files** (a local file tree). Opening a file
from it lifts a code editor overlay over the terminal. (#158)
- **Port forwarding and SFTP live in the detail panel** — a native-SSH pane's
forwards are listed, added and removed from the Info tab, and its remote
filesystem browses from the Files tab, with transfers reported in a footer
that rides under every tab. (#166)
- **Shell integration in native SSH panes** — OSC 133 prompt marks, exit
codes and cwd reporting now reach zsh, bash and fish on the far side, so
the inline line editor works in an SSH pane exactly as it does locally.
(#152)
- **Live drag-to-reorder** — tabs rearrange under the cursor the way Chrome
and Warp do (the dragged tab stays in the list and the ones it passes slide
out of the way) in the strip, the sidebar rows, and whole repo groups.
(#151)
- **The rail's top strip drags the window** — the sidebar's title-bar-height
top zone now moves the window on drag and zooms it on double-click, like
the title bar it sits level with. (#153)
- **The ⌃R history menu can be switched off** — Settings → Terminal →
Keyboard, or `history_search` in `config.json`. With it off, the prompt
line is handed to the shell and the raw `^R` follows it, so a binding
of your own (fzf, percol, plain reverse-i-search) answers instead of
tty7's menu. (#163, #170)
### Changed
- **Sidebar and right-panel visibility are per-window** — toggling one
window's chrome leaves the others alone. The config value is now what a
*newly opened* window starts with; panel width stays shared. (#169)
- **Chrome icons draw at the size they were asked to** — every tile glyph in
the window (title bar, rail, panel tabs, overlay headers) had been rendering
at 12px regardless of the size its call site set, because the button widget
overwrites its icon's size from its own. The tile rhythm is now stated once
and applied from one helper, so the marks read at their intended weight and
the hover capsule keeps a consistent gap from the window edge. (#169)
- **The panel and chrome glyphs are drawn to one spec** — the detail panel's
tab marks and the title bar's own were redrawn so a row of icons stops
reading as a row of icons from different sets, and every icon tile shares
one soft hover fill. (#161)
- **Multi-line commands submit as one bracketed paste** — a 30-line block
costs one prompt cycle instead of thirty, so it no longer retypes itself
down the screen on Enter. (#164)
- **The file tree lists directories off the render thread** — `read_dir`, the
`.gitignore` chain and the search walk left the paint, so a cold cache or a
large repo no longer stalls a frame. (#160)
- **The LSP client is gone** — opening a `.rs` file in the code panel silently
spawned rust-analyzer, which indexed the whole workspace for hundreds of
megabytes of RAM. A terminal shouldn't do that to you on a click, and the
fix is removal rather than a switch for something nobody asked for. (#159)
- **Sidebar rows float their close button** — the ✕ no longer reserves a
permanent trailing column, so labels and branch lines get the full rail
width, and it stays clear of a row's `+n −n` counts. (#145)
- **Daemon wire protocol is now v2** — WSL panes carry a remote-context kind
that a v1 client can't decode, so it would drop the pane's connection
instead of ignoring the unknown value. The version handshake now sees that
skew and offers to restart the daemon, rather than letting a downgraded
build lose panes silently. (#169)
- **`Cargo.lock` is checked against `Cargo.toml` in CI** — release builds now
run `--locked`, so a drifted lockfile fails the build instead of quietly
resolving something else. (#150)
### Fixed
- **⌃J and ⌃M submit the line again** — accept-line's control codes were
swallowed at the prompt as unrecognized Ctrl chords, so the keys did
nothing. They now take the same path Enter does, completion picker and
history menu included. (#163, #170)
- **The sidebar's git counts keep up** — `+N −N` refreshes as an agent's tool
calls land (throttled per repo) and when the window regains focus, instead
of going stale until you happened to run a command in that pane. (#149)
- **`ToggleSftp` earns its name** — pressing it while the panel is already on
Files closes the panel rather than being a dead press, and the remote
browser's 500ms transfer poll now ends itself when the panel closes instead
of depending on the render path to retire it. (#167)
- **Closing the last window from the home page quits** — it used to leave a
windowless process in the Dock that no longer responded to being clicked.
(#147, #148)
- **Windows titlebar chrome** — the `⋯` menu sits back on the native
window-control rhythm, and the sidebar's collapse / new-tab buttons take
their clicks again instead of being swallowed by the drag region. (#162)
- **Windows: the theme panel's close button did nothing** — and the repo group
header showed a plain arrow where every other row shows a pointer. (#155,
#156)
- **Settings keeps its stock glyphs** — the page's own icons stopped being
restyled by the chrome tile pass, and its close button is sized to the title
bar's rhythm rather than reading undersized beside the window controls.
(#157, #165)
## [26.7.2] - 2026-07-21
### Added
- **WSL shell integration** — prompt marks, cwd tracking, and agent
detection now work inside WSL distros. The integration tags the shell
it actually launches, never blocks the spawn path while probing the
distro's shell, and declines untranslatable cwds rather than
misreporting them. (#134)
- **Git Bash shell integration on Windows** — Git Bash panes get the
full integration, reporting proper Windows paths for cwd-dependent
features. (#130)
- **Smart double-click selection** — double-click selects the word under
the cursor with language-aware boundaries, including CJK segmentation
(the OS tokenizer on macOS, jieba elsewhere); angle-bracket pairs must
hug their contents and contraction apostrophes stay out of quote
pairing. (#128)
- **Configurable file-link command** — file links can open with a custom
command instead of the default editor. Contributed by @ayamir. (#143)
- **Agent resume keeps launch flags** — resuming an agent session
carries the flags the agent was originally launched with onto the
resume command. (#144)
- **Sidebar git line follows the agent's cwd** — the branch/status line
under an agent tab tracks the directory the agent is actually working
in, not just the pane's shell. (#127)
### Changed
- **Tab close button floats over the label** — the close affordance no
longer reserves a slot in every tab, so labels get the full width
until hovered. (#126)
- **Dependency updates** — resvg 0.47, sha2 0.11, a cargo minor-patch
group bump, and newer CI artifact actions. (#137, #138, #139, #140,
#141)
### Fixed
- **Tab completion falls through to the shell** — a Tab tty7 has no
candidates for now hands the line to the shell's own completion
instead of being swallowed (remote panes included); `cd`/`pushd` stop
offering files; and the whole feature can be turned off with
`tab_completion` in config or Settings → Terminal → Keyboard. (#146)
- **Remote panes never leak local filesystem operations** — a remote
pane's cwd is no longer used for local path lookups, and the agent-cwd
bypass validates inherited directories. (#133)
- **macOS text input goes through the IME** — synthesized keystrokes
keep their text (remote/automated input no longer degrades), and Kitty
full mode stays off the IME path. (#132)
- **No more console flashes on Windows git probes** — git status probes
and ssh ProxyCommand children no longer flash console windows. (#129)
- **Windows titlebar menu spacing** — the titlebar "..." aligns with the
window-control rhythm. (#131)
- **Codex avatar and macOS tray sizing** — the Codex tab avatar uses its
black brand field, and the tray icon renders at 22pt instead of the
hardcoded 18pt. (#142)
- **Agents settings layout** — rows stay aligned and notes terse. (#125)
## [26.7.1] - 2026-07-17
### Added
- **Follow the OS appearance** — a "Sync with system" mode with separate
light and dark theme slots: the theme flips live when the OS switches
appearance, native chrome follows along, and picking a theme while
syncing writes the slot matching the current mode. Old configs are
unchanged (sync defaults off). (#121)
- **Mark as Unread on tabs** — the tab context menu can re-flag a finished
agent result you've already looked at, re-arming the unread badge on the
Done dot. Agent tabs only; disabled while the agent is still working.
(#120)
### Changed
- **"Duo" logo refresh** — a new brand mark (two offset session panes,
mint behind ink, with a prompt chevron) replaces the orange window
identity across every icon asset: app icon, logo, tray glyph, favicon,
and social preview. Bare macOS binaries (`cargo run`) now show the icon
in the Dock too. (#124)
### Fixed
- **Linked git worktrees group under their main repository** — the sidebar
keys groups on the repository home instead of each worktree's own root,
so a repo and its worktrees share one header while branch status stays
per-worktree. (#118)
- **macOS tray icon stays a template image in every state** — the
attention state no longer swaps in a grey non-template glyph that was
illegible on many menu-bar appearances; agent status lives in the
tooltip and tray menu. (#122)
- **No more console-window flashes from Windows agent hooks** — each hook
emitter frees its throwaway console before it can paint (debug builds
only; release builds were unaffected). (#119)
## [26.7.0] - 2026-07-16
First CalVer release: versions are now `YY.M.PATCH`, so the number says when
it shipped rather than what changed.
### Added
- **Coding-agent detection on Windows** — the agent status dot now works on
Windows: agents are detected from the shell-integration command capture
(no `/proc` there), hook status events reach the daemon via the agent's
ancestor console, and mark-derived detection only re-fires when the
command capture actually changes. (#115)
- **Clipboard image paste for agents off macOS** — pasting a screenshot into
a coding-agent pane on Windows/Linux stages the image to a temp file and
pastes its shell-escaped path (the same route drag-and-drop uses); Windows
BMP screenshots are transcoded to PNG since agent vision rejects BMP.
macOS keeps the higher-fidelity pasteboard read. (#117)
- **Nightly build channel** — `main` is built every night into a rolling
`nightly` prerelease with all six platform artifacts; the in-app update
check is prerelease-aware so nightly users ride the channel while stable
users never see it. (#114, #116)
- **Sidebar groups tabs by git repository** — vertical tabs cluster under a
repo header, groups persist across restarts, ⌘N numbering follows visual
order, and same-named repos are disambiguated by their parent directory.
(#110)
- **System tray icon with agent status menu** — a tray/menu-bar icon
summarizes agent activity across sessions and jumps to a pane from its
menu. (#105, #109)
- **Gradient and image theme backgrounds** — themes can render gradient or
image backgrounds with global window opacity and blur, hot-reloaded like
the rest of the theme. (#106)
- **Double-click a tab to zoom the window** — matching titlebar behavior;
rename moved to the context menu. (#103)
### Changed
- **Settings sheet refinements** — controls right-align in their rows with
hover feedback, the column is tighter, and the theme card is richer.
(#108)
- **Settings copy polish** — clearer wording throughout, and the SSH
security defaults stay visible instead of hiding behind a toggle. (#104)
### Fixed
- **Ctrl+C after a copy sends SIGINT again** — copying with Ctrl+C and
pasting to the PTY now consume the selection, so the next Ctrl+C reaches
the program instead of copying the same text twice. (#113)
- **Shell vi mode is supported** — vi-mode prompts are detected via durable
signals and no longer confuse the prompt gap hold. (#102, thanks @ayamir)
- **App cursor shape is respected** — programs that set the cursor shape
(e.g. vim) see it honored. (#101, thanks @ayamir)
## [0.17.0] - 2026-07-16
### Added
- **Per-tab context menu with worktree tabs** — right-clicking a tab chip or
sidebar row opens a menu: rename, splits, copy working directory, and a
close group. *New Worktree Tab* creates a git worktree under the repo's own
`.tty7/worktrees/<name>` (kept out of `git status` by a self-ignoring
`.gitignore`), with editable name / branch / start point and a live path
preview. Closing a tab that sat in a managed worktree offers to remove the
checkout — unless another pane still lives in it, and dirty checkouts
default to keeping. (#96)
- **Unread pane count in the tab status dot** — a split tab can finish
several agent turns while you're away; the green Done dot now swells into
a badge counting the unread panes (clamped at 9) and shrinks back once
every pane has been seen. (#98)
### Changed
- **The sidebar diff overlay is per-tab and side-by-side** — the overlay now
lives on its tab: switching away hides it, switching back restores it
(re-probing when the status cache disagrees), closing the tab drops it,
and Esc keeps working. The body switches from a unified diff to a
GitHub-PR-style side-by-side view with positionally aligned
removed / added columns. (#100)
- **macOS-style popup panels** — menus get a rounded pill highlight, inset
hairline separators, a 10px panel radius, and a floatier shadow;
searchable lists get a taller Spotlight-style search row, and the palette
viewport holds a whole number of rows so the last visible row is never
cut mid-height. (#97)
- **README rewritten as a minimal index** — feature details, keybindings,
and performance notes moved to `docs/features.md` (en + zh-CN); the
tagline now positions tty7 as a terminal workbench.
### Fixed
- **The agent dot no longer sticks on Waiting after you approve a
permission** — Claude Code has no "permission replied" hook, so a new
PostToolUse hook emits a tool-complete event and the first tool that
finishes after approval flips the dot back to Working. Existing hook
installs surface as Outdated in Settings → Agents with an Update
button. (#99)
## [0.16.1] - 2026-07-15
### Fixed
- **Sidebar diff overlay only opens from the `+N`/`−N` counts** — clicking
anywhere on a tab row's git line (branch icon, branch name) used to toggle
the diff overlay, hijacking ordinary clicks on the lower half of the row.
Now only the diff counts are the click target; the rest of the line
activates the tab like the rest of the row. (#95)
## [0.16.0] - 2026-07-15
### Added
- **Click a sidebar git line to open a working-tree diff overlay** — the
branch/diff row in the sidebar is now clickable and opens an in-app overlay
showing the working-tree diff against `HEAD`, file by file with expandable
hunks. It rides the shared git-status signal: when fresh numbers land that
disagree with what it shows, it re-probes the full diff so the overlay stays
live. (#92)
- **Window size and position are remembered across launches** — tty7 saves
the window geometry on quit and restores it next launch, re-centering if the
saved bounds no longer overlap any display. Can be toggled off with the
`remember_window_size` config key. (#94)
### Fixed
- **Attach replay no longer duplicates TUI output into scrollback** — the
daemon's replay ring is now segmented by the geometry its bytes were
recorded under, and attach replays a `Size` → `Snapshot` pair per segment.
Previously the whole ring replayed at the final PTY width, so any resize
during a session (a pane split, a window drag) re-wrapped older output and
a TUI's cursor-up redraws (Claude Code's inline renderer, most visibly)
landed mid-frame, flooding the reattached pane's scrollback with stale
frame copies that never existed live. The ring also caps its segment count
so a long-lived session with many resizes can't grow it without bound. (#91)
- **Agent hooks no longer hang when stdin is a terminal** — the hook runner
skips the stdin read when fd 0 is a tty, so a hook invoked interactively
(rather than with a piped payload) emits the bare event instead of blocking
forever on a read that never returns. (#93)
## [0.15.0] - 2026-07-15
### Added
- **Daemon protocol version handshake** — the GUI asks a running daemon
which wire protocol it speaks before reusing it; after an app upgrade a
mismatched daemon is kept alive and a prompt offers Keep Sessions or
Restart Daemon instead of silently killing every persisted session. (#90)
### Changed
- **Tab close affordance** hides until hover on the active tab too, so the
sidebar and tab strip read clean. (#90)
- **Command palette** no longer offers the Claude-only hook install entry;
Settings → Agents owns hook installs with per-agent state.
### Fixed
- **SSH connection state** shows as a corner status dot on the tab avatar
(amber connecting, green connected, red failed) in the same semantic
colors as agent dots, replacing a theme-grey border ring that read as no
state at all. (#90)
- **Sidebar git branch/diff line** is shared per repository: panes in one
work tree read one snapshot refreshed by whichever pane probed last, so
rows for the same directory no longer show stale or missing +/− counts.
(#90)
- **Command palette** no longer pins Connect/Save rows above command
matches for bare words like `java`; QuickConnect rows require a
host-like query (`@`, `:` or `.`). (#90)
## [0.15.0-beta.1] - 2026-07-15
### Added
- Per-agent hook integrations (Claude Code, Codex, Copilot CLI, OpenCode,
Pi) with install state and actions in Settings → Agents. (#87)
- CLI coding agents and the git branch are recognized and shown in the
sidebar. (#85)
- Multi-line prompt editor, plus an I-beam mouse pointer over text. (#80)
- SSH: Unix GSSAPI (Kerberos) authentication. (#81)
### Changed
- Splitting an SSH pane opens another SSH pane on the same host. (#83)
### Fixed
- The grid shifts up when wrapped command input overflows the bottom of
the screen, keeping the caret visible. (#86)
- Each tab keeps its own active pane across tab switches. (#84)
- The theme panel stays on-screen on narrow windows. (#82)
## [0.14.0] - 2026-07-14
### Added
- SSH connection manager: a native russh client with saved connection
profiles, password and public-key auth, port forwarding, and SFTP. (#74)
- Buffer search overhaul — richer in-terminal search with rebindable,
cross-platform shortcuts. (#75)
- Vertical tab sidebar, and Settings reworked into a full-window page. (#70)
- Tab title now follows the active pane. (#73)
- New Settings controls: bell, notify threshold, mouse reporting, and
session restore. (#68)
### Changed
- SSH saved profiles are now the single source of truth for connections. (#77)
- Bump memchr 2.8.2 → 2.8.3. (#69)
### Fixed
- SSH auth-sheet polish and softer primary buttons. (#79)
- The Cmd+F find bar now owns the top-right slot over the SSH action
icons. (#76, #78)
- Windows: stop the daemon before install/uninstall so it can replace
`tty7.exe`. (#72)
- Moved SSH forwards into the pane context. (#71)
## [0.13.0] - 2026-07-13
### Added
- SSH loopback forwarding for links. (#58)
- Editable keybindings: rebindable shortcuts, pane/tab actions, and a tmux
preset. (#65)
### Fixed
- Trim the first tab's left gap flush to the traffic-light reserve. (#62)
- Remove the active-pane corner indicator dot. (#63)
- CI: format code and platform-gate the ctrl glyph in the keymap test. (#67)
## [0.12.0] - 2026-07-13
### Added
- Ship a Linux AppImage alongside the tarball. (#55)
- Title-bar overflow menu for the command palette and settings. (#57)
### Changed
- Redesigned theme picker with a slide-in panel. (#56)
### Fixed
- Stop the title-bar strip from clipping the Windows close button. (#60)
- Restore the original terminal-window logo, reverting the branding
change. (#59)
## [0.11.0] - 2026-07-12
### Added
- File-based themes, an in-app theme editor, and a UI/branding refresh. (#54)
## [0.10.0] - 2026-07-11
### Added
- Tab completion now executes the completion specs' *dynamic generators*:
positions whose candidates come from the live system get real values — git
branches on `git checkout <Tab>`, container names for docker/podman,
`package.json` scripts for npm/pnpm/bun/yarn, cargo/rustup/tmux/brew/apt/pip
listings, and more. Scripts run off the main thread in the session's cwd
(800 ms timeout, output capped, short-lived cache) and their results merge
into the already-open menu as they arrive; a slow or failing generator just
contributes nothing. (#52)
- When shell integration never engages in a pane, pressing Ctrl+R now explains
why the history menu can't appear (once per pane, dismissed by the next
keystroke) instead of failing silently — naming the wrapper when a
figterm-style PTY shim (`kiro-cli-term`, `figterm`, `qterm`) is intercepting
the shell's OSC 133 reports. The chord still reaches the shell, so its own
reverse-i-search keeps working. (#46)
### Fixed
- `ssh <Tab>` (and scp/sftp/rsync) now completes host aliases from
`~/.ssh/config` — `Include` files honored, wildcard patterns skipped — and
hosts from `known_hosts`, instead of falling back to listing the current
directory. (#51)
## [0.9.0] - 2026-07-10
### Changed
- Ctrl+R history search is now a browsable menu: matching is fuzzy
(subsequence with word-boundary/consecutive bonuses; space-separated terms
must all match) blended with frecency, and the ranked candidates float
beside the prompt — matched characters highlighted, selection moved by
Ctrl+R/↓ and Ctrl+S/↑, Enter to edit, Cmd+Enter to run outright. An empty
query lists the whole history by frecency, so bare Ctrl+R is a "recent &
relevant" browser. The classic `(reverse-i-search)` line stays. (#45)
- History records now carry when the command ran and its exit code: new
entries are `<ts>\t<exit>\t<cwd>\t<command>`, written when the command
*finishes* (zsh `INC_APPEND_HISTORY_TIME`-style, exit code sniffed from
OSC 133;D daemon-side); older formats still load. The Ctrl+R menu shows
"ran 3h ago" and a `✗` badge on commands whose last run failed; timestamps
from zsh/bash history files are carried over when seeding. (#45)
## [0.8.0] - 2026-07-10
### Added
- Copy on select: an opt-in Settings → Terminal → Clipboard toggle (config
key `copy_on_select`) that copies a mouse selection — drag, double-click
word, or triple-click line, over terminal output or the prompt's command
editor — to the clipboard the moment the gesture ends, no ⌘C needed. Off
by default so a stray selection never overwrites the clipboard. (#34)
### Fixed
- The held-⌘ tab-number badges no longer stick on after the window loses key
status mid-hold (⌘-Tab, Spotlight, a click into another app). The ⌘ release
goes to whatever app is key by then, so the window never saw it; the badges
— and any pending reveal — are now dismissed on the activation flip itself.
## [0.7.0] - 2026-07-10
### Added
- Terminal ANSI colors (`color0`–`color15`) can now be overridden individually
via `ansi_colors.*` in `config.json`, layered on top of the active theme
preset — with a color picker per slot under Settings → Appearance → ANSI
Colors. Malformed values are ignored, and clearing an override falls back to
the preset's palette. (#37)
- Font ligatures can now be enabled for terminal text. A new optional
`font_features` config passes OpenType features (e.g. `{"calt": true}`)
through to the renderer, and Settings → Appearance grows a toggle for the
common `calt`/`liga` pair. Ligatures stay disabled by default for cell-grid
safety, and changes hot-apply to open panes. (#38)
### Fixed
- Ctrl+L now clears the screen while the prompt-local line editor is active.
The readline dispatcher used to swallow it as an unrecognized chord; it now
forwards the same form-feed byte the raw terminal path sends, so the shell
repaints its prompt as expected. (#36)
## [0.6.2] - 2026-07-08
### Changed
- Context menus and the "+" dropdown now highlight the hovered row with the
same soft fill the command palette uses for its selected row, instead of the
stock saturated accent that snapped hard against the rest of the UI. The
hover text stays at the normal foreground so it reads clearly on the quieter
fill.
### Fixed
- On Windows, a pane no longer hangs open when its shell exits on its own.
Typing `exit`, pressing Ctrl-D, or a shell crash ends the shell, but ConPTY's
output pipe never reports EOF on a natural exit — and tty7 detected a shell's
death solely from that EOF — so the pane was left wedged open, dead but
visible. A Windows-only monitor now waits on the shell process directly and
reports the exit through the same path a Unix `read()` EOF drives, so the pane
closes as it does everywhere else. Closing a tab from the UI was already
unaffected; macOS and Linux are unchanged. (#30)
- Nerd Font prompt icons no longer render sliced off on the right. A non-Mono
Nerd Font (and the proportional `➜`/`❯` the OS cascade hands back when nothing
in your font list covers them) gives an icon a single-cell *advance* but draws
ink up to ~1.9 cells wide, and tty7 clipped every lone glyph to exactly one
cell — severing the overflow into the half-icons and cut-off arrow from the
report. A single glyph now paints into a two-cell window, so it renders whole
(bleeding into the trailing blank the way iTerm2 and Terminal.app do), bounded
at two cells so a stray face can't smear across the row. Pairs with the native
powerline separators from #19; Mono Nerd Fonts are unchanged. (#17)
- New tabs and splits no longer stall for seconds while a zsh plugin manager
reinstalls itself. tty7 launches zsh through a throwaway `ZDOTDIR` (so it can
layer its shell integration on top of your config), but it used to leave
`ZDOTDIR` pointing at that empty temp dir the whole time — so tools that find
their own state via `${ZDOTDIR:-$HOME}` (Zim, oh-my-zsh, `compinit`'s
`.zcompdump`) looked in the wrong place and rebuilt from scratch on every
pane, e.g. Zim reprinting `modules/…: Installed` and hanging for ~3s. Each
redirector now points `ZDOTDIR` back at your real config dir while your
startup files run, and restores it for the live session, so plugin managers
and completion caches resolve correctly and load instantly. As a bonus this
also fixes the classic relocated-config layout (a tiny `~/.zshenv` that sets
`ZDOTDIR=~/.config/zsh`), which previously loaded your config but silently
dropped tty7's integration. (#15)
## [0.6.1] - 2026-07-08
### Fixed
- Tab completion (and other line editing) now stays out of the way over `ssh`.
A remote shell that emits its own prompt marks — fish 4.x on a Linux server,
most visibly, which ships OSC 133 on by default — used to engage tty7's
*local* line editor, so Tab ran completion against the local machine's
filesystem instead of reaching the remote shell. tty7 now only drives the
inline editor while the shell it launched is itself idle at its prompt;
whenever a foreground command (ssh, a TUI, a nested shell) owns the terminal,
keystrokes pass straight through to it. (#26, follow-up to #18)
## [0.6.0] - 2026-07-08
### Added
- The "+" button now opens a shell picker: tty7 detects the shells installed
on this machine (on Unix the login shell, `/etc/shells`, plus well-known
shells found on `PATH` — fish, nushell, pwsh and friends installed by
Homebrew/nix are never registered in `/etc/shells`; on Windows PowerShell 7,
Windows PowerShell, Command Prompt, Git Bash and WSL distributions)
and lists them in a dropdown, so opening a tab in a different shell
no longer requires editing `config.json`. The default entry leads the menu,
⌘T / Ctrl+T still opens a default tab in one keystroke, and splitting a pane
inherits its shell — a fish tab splits into more fish, not back to the
default. Shells picked this way aren't remembered across restarts (restored
panes re-attach to their still-running shells anyway).
### Changed
- The Windows default shell now prefers PowerShell 7 (`pwsh.exe`) when
installed — probed across Program Files (x64/x86/ARM), the Microsoft Store,
scoop, dotnet tools and `PATH` — and falls back to Windows PowerShell as
before. Set `shell` in `config.json` to override, as ever.
### Fixed
- Powerline prompt separators (powerlevel10k, oh-my-posh, oh-my-zsh) now render
pixel-perfect at any font, size and line-height: the eight solid separators
(sharp triangles, round caps, slants) are drawn natively as fill paths sized
to the exact cell instead of relying on a Nerd Font, so segments meet their
backgrounds cleanly with no gaps, narrow wedges or tofu. The bundled Hack font
is also appended to every font-fallback chain, so common prompt glyphs (➜, ❯,
box drawing) no longer render truncated or missing when no Nerd Font is
installed. (#17)
- A URL glued directly to a full-width bracket with no space — e.g.
`…/pull/343(fix/… → dev)` — no longer swallows the bracket text into the
link. URL detection now stops at the first non-URL character (a CJK glyph,
full-width bracket, arrow or emoji), while interior ASCII parens
(Wikipedia-style URLs) are still preserved.
- Fish, nushell, pwsh and other shells installed by Homebrew or nix now appear
in the "+" shell picker even when they aren't registered in `/etc/shells`
(which those package managers leave to the user): a curated set of well-known
shells is now probed on `PATH` as a catch-all, after the `/etc/shells`
entries. (#18)
- Upgrading tty7 while an older daemon is still running in the background no
longer breaks new tabs. A stale daemon that accepts the connection but can't
serve the new client's request is now restarted once and retried
automatically; on macOS the GUI also forwards the shell it was launched with
to the detached daemon, so panes use the right shell instead of a stale
`$SHELL` inherited from LaunchServices.
## [0.5.0] - 2026-07-07
### Added
- Windows releases now ship an Inno Setup installer
(`tty7-<version>-windows-x86_64-setup.exe`) alongside the portable zip. It
installs per-user by default (no admin prompt, with an all-users option),
adds a Start Menu shortcut and an "Apps" uninstall entry, and offers an
optional desktop icon. Still unsigned, so SmartScreen warns on first launch.
- Startup update check: tty7 asks GitHub once, in the background, whether a
newer release has shipped. If so, it pops a one-time "Update available" dialog
(once per version — remembered in `update.json`, so it never nags twice for
the same release) and keeps a persistent "Download" prompt in Settings →
About. Both open the Releases page; tty7 never downloads or updates itself —
you still install by hand. Turn the check off with `check_for_updates` in
`config.json` or the "Check for updates on launch" toggle in About. A failed
or offline check is silent.
- ⌘K (Ctrl+K on Windows/Linux) clears the screen and scrollback — the same
"Clear" the right-click menu already offered, now on the keyboard shortcut
Terminal.app, iTerm2, and Ghostty users expect. Also available from the
command palette, and remappable as `ClearScrollback` in `keybindings`.
- ⌘⏎ toggles window fullscreen (new `ToggleFullscreen` action, also in the
View menu and command palette), matching the Ghostty/iTerm2 default. It
previously toggled pane maximize — which silently did nothing in a
single-pane tab, so the chord felt dead.
- The right-click menu now shows each item's keyboard shortcut. Copy, Paste,
Select All, and Find previously showed nothing (they're dispatched inline,
with no bound key for the menu to read a hint from) while the other items
did, so the menu looked half-labelled. ⌘A / ⌘F stay hint-less on
Windows/Linux, where those chords keep their readline meaning.
### Changed
- Maximize / restore pane moved from ⌘⏎ to ⌘⇧⏎ (Ghostty's `toggle_split_zoom`
default), making room for fullscreen on the bare chord. An existing
`ToggleMaximizePane` override in `keybindings` still wins.
### Fixed
- Windows: launching tty7 no longer opens a stray console window behind the
app. Release builds are now linked with the `windows` subsystem; debug
builds keep the console so `println!` output stays visible. (#10)
- The right-click "Select All" now matches the ⌘A shortcut: at the prompt it
selects the edited command line, otherwise the whole terminal buffer. It
previously always selected the whole buffer, so click and keystroke behaved
differently at the prompt.
- Ctrl+R reverse-search now accepts plain ASCII keystrokes. The query only
took text from the IME commit path, so a non-CJK input source on macOS — and
all typing on Linux — was swallowed: the search box opened but ate every key.
Reported on V2EX.
## [0.3.0] - 2026-07-07
### Added
- PowerShell shell integration: `powershell.exe` and `pwsh` now emit the OSC 133
semantic-prompt marks and OSC 7 cwd that zsh/bash/fish already do, injected
via `-EncodedCommand` after the user's profile loads (their config is never
touched). This turns on the inline line editor at the PowerShell prompt — so
clicking positions the caret and new tabs/splits inherit the working
directory — which is what previously made mouse clicks a no-op at the prompt
on Windows.
### Fixed
- Typing `exit` (or Ctrl-D) left a dead "process exited" pane behind instead
of closing it. A pane whose shell genuinely ends now closes itself —
collapsing its split, or closing the tab when it was the only pane (the
last tab falls back to the home page), like every other terminal. A pane
that merely *lost its daemon connection* still stays visible: auto-closing
those would silently discard — and kill — sessions that may still be alive
daemon-side. Panes that died while detached clean themselves up on the next
attach the same way.
- A full-screen TUI dying without restoring the terminal — the canonical case
being an ssh session dropping mid-`htop`/`vim` — left the pane stranded on
the alt screen with a hidden cursor and live mouse reporting: a visible
prompt with no cursor anywhere, mouse clicks echoing `0;19;42M`-style junk,
and broken scrollback. The client now scrubs this residue the moment the
shell reports its next prompt (OSC 133): it leaves the stranded alt screen,
re-shows the DECTCEM-hidden cursor, and disables stale mouse/focus reporting
and kitty keyboard flags — each reset only when its mode is actually set.
Reattach self-heals the same way, since the daemon replays the prompt state
after the ring.
- Windows shell integration never engaged even for the default shell: detection
keyed off `portable-pty`'s `get_shell()`, which reports `%ComSpec%` (cmd.exe)
regardless of what's actually spawned, so the PowerShell default was mistaken
for an unsupported shell. It now resolves to `powershell.exe` directly.
## [0.2.0] - 2026-07-04
### Added
- Underline styles: undercurl, double, dotted, and dashed underlines render distinctly.
- `config.json` hot reload — edits apply to the running app without a restart.
- Desktop notifications driven by OSC 9 / OSC 777 escape sequences.
- Kitty keyboard protocol (CSI u progressive enhancement) for TUI apps like Neovim and Helix.
- Shell integration for bash and fish, alongside the existing zsh support.
- Windows support: cross-platform daemon, PowerShell as the default shell, embedded app icon.
- Linux support: builds against gpui's x11/wayland backends, `/proc`-based foreground cwd + pane-title tracking, Linux CI job, and documented build dependencies.
- Downloadable builds for every platform: the release workflow now packages and uploads all four targets — signed/notarized macOS DMGs (arm64 + x86_64) plus unsigned archives for Windows (`.zip`) and Linux (`.tar.gz`), each via its own `.github/scripts/bundle-<os>` script.
- Settings UI: terminal / appearance / behavior options are configurable from the GUI, with a searchable font-family dropdown and a wider theme gallery.
- Configurable default shell.
### Changed
- Project renamed to **tty7**.
- macOS releases ship as drag-to-Applications DMGs instead of zips, and the
Intel build moved to the `macos-15-intel` runner (`macos-13` was retired,
which had silently kept x86_64 assets from ever publishing).
- Pixel-smooth scrollback: scrolling carries a sub-line fraction and shifts the paint instead of jumping whole lines.
- Smoother scrolling on dense screens: glyph shaping is batched and wakeups are coalesced.
- CJK-dense screens paint ~2.4× faster: consecutive wide glyphs batch into single shaped runs (two columns per glyph) instead of painting cell-by-cell; the grid snapshot buffer is reused across frames and the selection/search overlay scans are skipped when nothing is highlighted. Release builds now use thin LTO.
- Type-ahead is integrated into the line editor instead of being stranded on zle's line.
- New tabs open next to the active tab instead of at the end.
- Terminal throughput ~12× faster (11 MB `cat`: ~2.0 s → ~0.16 s; DOOM-fire: ~47 fps → ~920 fps, both at 155×40 on an M1 Pro — now ahead of Alacritty/Ghostty on the same machine): the daemon's replay ring is a `VecDeque` so a full ring no longer memmoves 8 MiB per ~1 KiB PTY read, and the per-connection writer coalesces queued `Output` frames (≤256 KiB) so a flood reaches the client as a few large frames instead of thousands of tiny ones. A backpressure gate (4 MiB high-water) pauses the PTY reader while the client catches up, so a runaway `yes` can't grow daemon memory without bound. `TTY7_TRACE=1` prints per-second reader-loop accounting on both sides for future diagnosis.
- Second throughput pass, another ~1.4× on bulk output (11 MB `cat`: ~160 ms → ~100 ms; sustained plaintext drain 124 → 148 MB/s, vs ~170 MB/s for a raw do-nothing PTY reader on the same machine; DOOM-fire is unchanged — it is producer-bound at ~96 MB/s): the backpressure high-water grows to 16 MiB so a big burst drains at PTY speed while the client parses in its own time; daemon⇄GUI socket buffers grow from macOS's 8 KiB default to 256 KiB; the client applies consecutive `Output` frames as one batched parser pass (one term-lock + wakeup per burst, latency-free — the batch never waits for unarrived bytes); the shared OSC tokenizer skips Ground/Ignore runs with SIMD `memchr`; the gate's hot path is a lock-free atomic (previously a Mutex plus an unconditional `notify_all` per socket write); and the four threads on the interactive output path ask macOS for `USER_INTERACTIVE` QoS to stay off the efficiency cores (`TTY7_NO_QOS=1` opts out).
### Fixed
- A long `--config-dir` path crashed the GUI at startup ("path must be shorter than SUN_LEN"): when `<config>/daemon.sock` would exceed the OS socket-path limit (104 bytes on macOS), the endpoint now falls back to a short per-user path keyed by a stable hash of the config dir ($XDG_RUNTIME_DIR, else the OS temp dir). Short paths keep the original layout, so existing daemons stay reachable.
- Typing right after a command finished could leave a stray echoed character plus zsh's reverse-video `%` in the scrollback: the "command finished" mark (OSC 133;D) is now emitted the instant the command exits — prepended ahead of the user's precmd hooks (zsh/bash) — instead of after slow prompt frameworks (oh-my-zsh git status, conda), so the local input editor takes keystrokes back hundreds of milliseconds sooner.
- Typing while a command was still running stranded those keystrokes on zle's line at the next prompt — un-editable and double-drawn under the line editor's overlay. Type-ahead adoption (wipe the shell's line, seed the editor) now runs at every prompt, not just the shell's first, and the wipe waits until zle is actually reading (the live `133;B` mark) so it is consumed silently instead of being kernel-echoed into the scrollback as a literal `^U`.
- Typing ahead of a fast command left kernel-echoed debris in the scrollback (`ls` plus zsh's reverse-video `%`). Reconstructable gap input is now held client-side for up to 150 ms: a command that finishes inside the window hands the keystrokes straight to the line editor with the PTY untouched — zero echo; a longer command (or one reading stdin) gets the bytes released verbatim, so REPLs and password prompts still work.
- fish shell integration silently never installed, so fish users got no prompt marks or cwd tracking.
- **Security:** pasted clipboard content is stripped of ESC bytes, closing a bracketed-paste escape that could inject auto-executing commands.
- Crash when copying/cutting right after a forward word/line delete left a stale selection anchor.
- `Ctrl+Alt+<letter>` was indistinguishable from `Ctrl+<letter>` because the legacy key encoder dropped the Alt ESC prefix.
- Plain Enter/Tab/Backspace were wrongly CSI-u-encoded at the kitty-keyboard DISAMBIGUATE level, which could wedge the shell after a crashed TUI.
- No-op edits (e.g. Backspace at the start of the line) no longer swallow the first undo.
- OSC scanners (daemon-side and notification-side) dropped a well-formed sequence that directly followed an unterminated one.
- Daemon pane teardown is hardened: process-group kill, bounded join, dead panes are reclaimed.
- New shells default to `$HOME` when launched from the app bundle with cwd `/`.
## [0.1.0] - 2026-06-30
Initial release.
- Sessions live in a persistent daemon and survive window close / app restart.
- GPU-rendered terminal grid on [gpui], backed by Zed's `alacritty_terminal` fork.
- Tabs and pane splits (split right/down, maximize, focus movement).
- Command palette with fuzzy search over every action.
- Smart line editing: inline completion, syntax highlighting, history, in-terminal search.
- zsh shell integration (OSC 7 cwd + OSC 133 prompt marks) via a throwaway `ZDOTDIR`.
- Native macOS light/dark themes that follow the system appearance.
[26.9.1]: https://github.com/l0ng-ai/tty7/compare/v26.9.0...v26.9.1
[26.9.0]: https://github.com/l0ng-ai/tty7/compare/v26.8.3...v26.9.0
[26.8.3]: https://github.com/l0ng-ai/tty7/compare/v26.8.2...v26.8.3
[26.8.2]: https://github.com/l0ng-ai/tty7/compare/v26.8.1...v26.8.2
[26.8.1]: https://github.com/l0ng-ai/tty7/compare/v26.8.0...v26.8.1
[26.8.0]: https://github.com/l0ng-ai/tty7/compare/v26.7.6...v26.8.0
[26.7.6]: https://github.com/l0ng-ai/tty7/compare/v26.7.5...v26.7.6
[26.7.5]: https://github.com/l0ng-ai/tty7/compare/v26.7.4...v26.7.5
[26.7.4]: https://github.com/l0ng-ai/tty7/compare/v26.7.3...v26.7.4
[26.7.3]: https://github.com/l0ng-ai/tty7/compare/v26.7.2...v26.7.3
[26.7.2]: https://github.com/l0ng-ai/tty7/compare/v26.7.1...v26.7.2
[26.7.1]: https://github.com/l0ng-ai/tty7/compare/v26.7.0...v26.7.1
[26.7.0]: https://github.com/l0ng-ai/tty7/compare/v0.17.0...v26.7.0
[0.10.0]: https://github.com/l0ng-ai/tty7/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/l0ng-ai/tty7/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/l0ng-ai/tty7/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/l0ng-ai/tty7/compare/v0.6.2...v0.7.0
[0.6.2]: https://github.com/l0ng-ai/tty7/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/l0ng-ai/tty7/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/l0ng-ai/tty7/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/l0ng-ai/tty7/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/l0ng-ai/tty7/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/l0ng-ai/tty7/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/l0ng-ai/tty7/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/l0ng-ai/tty7/releases/tag/v0.1.0
[gpui]: https://github.com/zed-industries/zed