`codesign -d -r-` writes the requirement to stdout and puts only the `-d`
display header (`Executable=…`) on stderr. `signing_requirement` searched
stderr, so the `designated => ` prefix could never match and every in-app
update on macOS ended at "codesign did not report a designated
requirement" — every build, every channel, with nothing a user could do
but download the app again by hand.
Verified against codesign rather than reasoned about:
$ codesign -d -r- /bin/ls
stdout: designated => identifier "com.apple.ls" and anchor apple
stderr: Executable=/bin/ls
Both streams are read now, stdout first. Which half goes where is
codesign's own business and has moved before; a requirement printed
anywhere in the output is the requirement, and the updater has no reason
to be the stricter party about where it appeared.
The parse is split out of the process call, which is the part that
matters for it staying fixed. Fused to `Command::output`, it could only
run against a real signed bundle, so nothing in a test suite ever
executed it — that is why a total failure of the macOS update path
shipped and stayed. `/bin/ls` is the bundle it was missing: Apple-signed,
on every macOS, and it answers `-d -r-` with a requirement of its own, so
the stream split is now asserted against the tool instead of against our
belief about it.
Both tests were run against the old stderr-only parse; both fail there.
260 KiB
Changelog
All notable changes to tty7 are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
-
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, anddocument_layoutfor 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_editorinconfig.json, on by default). Turned off, tty7 stops editing the shell prompt: every keystroke there — printable keys, arrows, IME commits, paste, ⇥ and ⌃ R — 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 andtty7 procskeep 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/configalias; 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-Jhop, 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.
Changed
-
The Info panel's
agentrow is gone. It saidClaude Code · workingbeside 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
fishstarted with your flags had-C <script>appended to them and azshhad itsZDOTDIRswapped out from under its startup files. Both now behave like the rest. This turns shell integration off for a config that setsshellor acustom_shellsentry withargs— 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, ⌃ R 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/configarrives nameless, and a window full of them all readtty7until the far shell happened to title itself. The name survives a title reset and a dropped link (prod-web — disconnected), and a tab titleddeploy@10.0.0.5:2222is shown whole rather than cut down to2222: theuser@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 nametty7 ws lsprints andtty7 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 withws lsfrom the start. A workspace with no name, which is whattty7 newleaves 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_commandkeeps it. -
A pane address may now be written without its
%—tty7 pane ls --jsonprints bare ids, so83addresses the same pane as%83everywhere an address is taken. One behaviour goes with it: a lonetty7 send 83used to type "83" into the caller's own pane and now reports that it has nothing to send, the same as a lonesend %83always 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).
Fixed
-
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 readcodesign -d -r-'s answer off stderr — where codesign puts only theExecutable=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 runleaves behind — showed up nowhere in the GUI; only the CLI'stty7 pane ls --allcould see it, and onlypane close --orphanscould 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 %99fails 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 underfailedwith 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.jsonholding one gets alog::warn!naming the path at the moment the fallback engages (#601). -
tty7 doctorexits 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 whiletty7 doctor || alertnever fires. The full table and JSON still go out, and stderr carries the headline under-q(#592). -
The
ownerfield ofpane ls --allis 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 waitnow 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 disciplinepane closealready set (#590). -
A timed-out
tty7 waitnow answers in the same JSON shape as a finished one —matched,staleand 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, includingtimed_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.confon a server opened the one on your laptop, silently. Paths are now resolved on the pane's own host, and a pane runningsshtyped 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 localopenor 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 quarantineviews.jsonalready had), and the stand-in defaults carry a mark that makessaverefuse 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 sendaddress no longer types itself into your own pane —tty7 send %3x --key C-cused to degrade the unparseable%3xinto 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 rmdocs 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 withpane ls --all, when the code has hung them up since the command exists; only a failed hang-up (whichws rmreports by pane id) leaves orphans behind. The site reference, the bundled skill reference, andws rm --helpall 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 fromtab.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 — readsUSERPROFILEas well asHOMEand compares with separators normalized and case folded, so aC:/Users/…pane shortens under aC:\Users\…home (#544). -
A hand-edited keybinding takes effect without a restart. The config watcher reloaded everything except the keymap, so a
keybindingsedit 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 ownsave(), 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), sofont_size: 50shrank to 48 on "+" and wrote that back, permanently changing a value it only meant to nudge. The steppers andsanitizenow 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: 5000highlighted "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"]inconfig.jsonrefilled the field as-c echo hiand 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 --enternow presses Enter when there is nothing to type —--enteris shorthand for--key enter, but it was never counted as a key, sotty7 send %42 --enteranswered "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 --enterpresses Enter in your own pane). An unmarked id is deliberately left out:tty7 send 83 --enterreads 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$HOMEwhoever the path belonged to, so a server sitting in/home/deploy/appread as~/appon a laptop that happens to log in asdeployand 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 hasssh'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_commandtemplate 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)
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
ansi16and 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_sizeinconfig.jsonscales 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 offfont_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_historylooks 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
execveon handoff — same pid, same children, same descriptors, same locks — writing what it knows about each pane into a blob and clearingFD_CLOEXECon 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 3placeholder, 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
changesrow is now the sidebar's green and red+N −Mand 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 aslocalhostwhen localhost actually reaches it — a server bound to172.17.0.1is 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 mapssession.statusbusy/idle for versions that no longer emitsession.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
PaneMoveinstead 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 allPaneMovecan 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 nativeDWMSBT_TRANSIENTWINDOWmaterial on Windows 11 22H2+ (classic acrylic before that), and Blur viaACCENT_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 -9or 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 waitcan wait for a command, not just an agent — a newfreestate ends the wait when the pane's foreground command has exited and the pane is back to its bare shell, which is what acargo testrunning in a pane has instead of an agent status. With--changedit means "something ran and then finished", the shape you want on the line after asend. 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, sowaiting,done,freeon a pane of unknown kind cannot lose you awaiting. -
tty7 send --keypresses keys instead of typing characters —C-cto stop a runaway build,escapeto close a TUI,up/down/enterto answer a permission prompt that takes no text. Repeatable for a sequence, composable withTEXT, 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 closetakes several panes, and--orphansclears the lot —pane ls --allhas been able to show the panes an interruptedrunleaves 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 doctorreports where the agent status hooks stand — it has claimed to check hooks in its own--helpfor a while without doing so. Missing or outdated hooks are why an agent can look frozen intty7 agentsand whytty7 waiton it only ever times out, so the check belongs in the verb people run when something is not working. -
SSH probes the
~/.sshdefault 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_ecdsaandid_rsanow stand in for the identity list when the connection names no key of its own, the wayIdentityFile'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'sMaxAuthTrieswhether 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 overWSLENVand pointed at withZDOTDIR. 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 aZDOTDIRaimed 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_shellsinconfig.jsontakes 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.shellstill names the one command that stands in for the platform default; these are the rest. An entry with noprogramis skipped, one with nolabelis 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 Filestakes 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_panesfades, 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 -qhas 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.Lxssis wherewsl.exeregisters them, nothing is listening on it, and it cannot hang;wsl -l -qstays as the fallback for when the key will not open at all. Distros are filtered byState, so a cancelled install or a failed--importno 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.execalls 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-provinguname,$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 --jsonnow 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 theCTRL_C_EVENTnever came, sogo runandnpm installcarried on regardless. Git Bash looked fine only because MSYS synthesises SIGINT from the byte itself.DETACHED_PROCESSalready 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
$HOMEwhile everything else an instance owns resolved from the config directory, so--config-dirmoved 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 onemachine.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-dirone 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 -srcfoldersizes 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 rmstays removed instead of being written back under the same id and haunting the switcher until a restart;tty7 lscan name the host holding a workspace; andtab ls/ws treefall 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-clithere 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 failingclaude --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 beforeshcould receive it, so the pane never started at all. Every invocation carrying an argv goes out under--execnow. 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, andtty7 waitand busy/idle status dead in the pane. There is a fish arm now, POSIX-quoted sinceshparses 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.exefailed, andwsl.exestarts 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_sizeentirely — 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_clamplimits 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:porton 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
\nwhatever 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
→ editbadge 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 completesgreprather 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 fora && banda; 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).extthe 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
--colordescribed 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:~/dirreads…/dirin both places. In a split, the pane running an agent names the tab rather than whichever shell happens to be first.tty7 tab lsand 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
/mntkeeps its own.bashrc— the bootstrap handed bash a--rcfileliving on the Windows side without checking the distro could read it. bash ignores an unreadable--rcfilewithout a word, and tty7 starts it non-login precisely so that--rcfileis honoured at all — so a distro with automount off, or a root moved bywsl.conf, opened a shell that had read no startup file the user wrote: not tty7's, and not their.bashrcor.bash_profileeither. 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 -uallabout 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.gitis an open, and reading.gitis 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
Included ssh config file is found again — the check behind a parked remote workspace watched~/.ssh/configfor changes and nothing else, butIncludeis common and editing an included file leaves the including file's timestamp exactly where it was. So re-adding aHostblock 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
yesto 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 inknown_hostsare 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.comwith no port, andproxy.example.com:88Owith 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,IdentitiesOnlyand the rest — grouped with the hosts that set them, so a host that behaves differently under tty7 than undersshhas 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
chdirs — soclaudeentering.claude/worktrees/featureleft the tree rooted in the main checkout, disagreeing with the path printed directly above it. -
tty7 waitno longer calls a busy shellidle— a pane with nothing reporting agent status was reported asidle, sotty7 wait %3 --until idlereturned success immediately,matched: true, about a pane that was midway through a build. Those panes now reportno-agent, which is both true and the signal to use--until freeinstead; 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-updaterhelper and quits, so a failure inside the helper used to reach onlyupdate.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.jsonnext toupdate.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-dirargument 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-updaterhelper 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 Filesinstall, 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 —
nightlyis force-moved every night, sotag_nameis the literal string — so it now publishesnightly.jsonbeside 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_proxyconfig field, the platform system proxy (Windows registry, macOSSCDynamicStore), andHTTP_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-scrapingcapture-paneon a timer.The status the server keeps is a level, not an event:
donestands until the next turn begins,waitinguntil the agent moves again. So--changedis 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 asendanswers with the previous turn's state before the worker has even read the input; the JSON'sstaleflag says when that happened. Exit codes are made for scripts:0matched,124timed out (thetimeout(1)convention),1with"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
AgentStatesrather 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 onetty7 agentsmakes once. (by @yetone in #248) -
An agent-facing skill for the CLI — the repo carries
skills/tty7, 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, withnpx skills add l0ng-ai/tty7— tty7 writes nothing into~/.claudefor 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 —
hasPreciseScrollingDeltasis 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 isMovedforever on every platform. The gesture is then held open on a 150ms idle timer rather than closed onEnded, because lifting the fingers is not the end of the stream: the momentum tail keeps deliveringMovedevents 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
zhandjakey tables are exhaustive, so adding anL10nKeyfails 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
~/.ompconfig directory, so it gets its own entry rather than sharing Pi's — a pane runningompwas previously not detected at all, and aliasing it onto Pi would have written the bridge to~/.piand offeredpi --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-menuflags, so the key layout stays incore::explorer_context_menuinstead 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) -
tty7opens 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 —
BellModegained aBothvariant alongsideNone,VisualandAudible, exposed as a fourth option under Settings → Terminal → Bell. Existingnone/visual/audibleconfig 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
Activatedhandler, 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
tty7CLI 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_rowlike 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
outlinefalls back to the default Info tab, and a user keybinding namingShowRightPanelOutlinedegrades 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 wayshell,paneandagentalready 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
Vecthe 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*UsageDescriptionkeys, 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_terminalnow has aconpty_resizemode 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, andssh.exeare 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
Sizeframe 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 liveSizeframes 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 (newresize-echofeature 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 blockingread()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
Exitedarriving 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'sexit(0)skipped every destructor that would have closed them: they kept the installedOpenConsole.exeopen for seconds after--stop-daemonreturned. 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 theDeleteFilecode 5 users hit even after "closing everything".Both shapes were reproduced in isolation first: with a pane open,
--stop-daemonreturned about a second in whileOpenConsole.exestayed 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-diralso terminates anything still running from the installation directory and only returns once the.exe/.dllimages 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 inupdate.loginstead 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 pollingkill(pid, 0), which a recycled pid could satisfy forever; a Windows update guard makesensure_runningrefuse to spawn a daemon mid-install, so atty7CLI 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
CreateProcesstime and never updates it, so a daemon that had been up since before an installer editedHKCU\Environmentgave a brand-new pane its startupPATHand the freshly installed command was unresolvable until tty7 restarted. Windows Terminal launched from Explorer found it, because Explorer rebuilds its own block when it seesWM_SETTINGCHANGE.Rather than chase broadcast messages, the daemon re-reads the two hives Windows itself composes an environment from — machine
Session Manager\EnvironmentandHKCU\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 soPathandPATHcollapse into one variable;PATHandPSModulePathare combined, machine first, which is what Windows does and what keeps a per-user install from shadowing the system half;REG_EXPAND_SZvalues are expanded against the merged map with a depth bound; the machine hive'sUSERNAME=SYSTEMis dropped as Windows drops it; and directories the daemon's ownPATHheld that neither hive lists stay reachable, appended behind the registry entries — fresheningPATHshould 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-ptyalready prefers a sideloadedconpty.dllover kernel32's, so this is packaging rather than code: the pair goes besidetty7-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/f5f5undercatppuccin_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, andbuild.rsstages 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…?25hso 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.lnkInno'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.lnkasynchronously andToast::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::themefield, which meant the GUI had to rewrite the user'sconfig.jsonevery time the effective preset changed sides. The hint moves to its ownappearance.jsonin 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
ProcessRedirectionTrustPolicyon 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'scurrentlinks are.oh-my-poshandfzfdied withShim: 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_PROCESSnaming 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
Exitedreached 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_commandcan 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_treeerased a pane's id when the tree saidlive: 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_withdecided another server was dead by failing to connect once, then unlinked its socket and bound its own, and the loser keptcontrol.sockwith an empty pane registry, soMachineGetreported every pane dead and nothing logged an error. Andfinish_hydrationmarked a window informed before the rebuild and without looking at the result, whiletabs_from_sessiondrops 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.jsonis quarantined the waymachine.jsonalready 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: noPATHbinaries 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, sols /etc<Tab>lists the distro's/etcand notC:\etc. Awsl.exepane spawned without--distributionresolves 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 thewsl.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_daemonhad 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--stdioworkspace 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
connectin 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 theMkdirresult 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/clipboardresolved from the session's ownrealpath ., and the directory is verified before anything goes into it: a symlink is refused outright, achmod 0700the daemon watched succeed is the ownership proof, and a followingstatmust report exactly0700. 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.pngand 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 whatxterm-256colorspellskcuu1and 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) -
$SHELLnames the shell the pane actually runs — a pane configured to run fish still advertised the login shell, because the pane environment injectedTERM, theTTY7_*markers andTERM_PROGRAMbut never touchedSHELL, so the pane inherited the GUI session's login-time snapshot. Everything that spawns "the user's shell" read that: tmux'sdefault-shellstarted zsh inside a fish pane, and so didsudo -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.SHELLis 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 soPATHdecides which install wins, so a bare name is resolved against thePATHthe pane will inherit and skipped when that finds nothing. An explicitSHELLin the user's env block still wins. Windows is deliberately left out — neither cmd nor PowerShell readsSHELL, 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—$SHELLis a snapshot the session inherits at login, sochshnever 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 reentrantgetpwuid_r, with$SHELLas the fallback. The same change fixes who wins a name in the menu:$PATHis probed before/etc/shells, so on a machine with a Homebrew bash the menu's "bash" is the binary typingbashwould reach rather than macOS's 3.2 from 2007. (#278) -
A forwarding
.bash_profileno longer sources.bashrctwice — the rcfile tty7 hands bash replays the login-shell startup chain, but then sourced~/.bashrcunconditionally afterwards. A login shell never does that on its own:~/.bashrcarrives only because the profile that won the chain forwarded to it, which is how nearly every~/.bash_profileis written. The result was the user's whole~/.bashrcrunning twice per pane — banners printed twice, completions sourced twice, appends toPROMPT_COMMANDstacking up.~/.bashrcjoins the same first-match-wins chain, which keeps the fallback for a$HOMEwith no profile at all. (#279) -
The macOS locale fallback sets
LANG, notLC_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 asLC_CTYPE, which backs only character handling. Collation, time and numbers stayed at C, and bash warnedsetlocale: 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.LANGbacks every category and still loses to anyLC_*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 Documentscompleted tocd 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 byMy DocumentsandMy MusicisMy, 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'sPATHin a remote pane, sosystem_prof<Tab>over SSH to Linux offered macOS'ssystem_profiler— builtins are true on any POSIX shell and still go out, thePATHscan 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
^Uafter 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_fgis 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,borderkeeps 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 sendsubmits 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)
-
tty7diagnoses 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 sameRefCell. 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 compiledsetup.execannot 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 ontty7-core, the crate the GUI's own daemon logic was extracted into — runs on the far machine and serves the identicalLocalHostimplementation 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⌃Rhistory entries below build on: none of them had anywhere to attach to before this landed. (#235) -
tty7is 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 renamedtty7-app(bundle id, icons and display name unchanged);tty7, the name you type, is now the CLI. Both are clients of the sametty7-servera 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,--keepkeeps the pane around),new,send,capture,split,procsAddressing ws/tab(@N) /pane(%N) /machine/serverIntrospection agents,events(NDJSON),status,doctorGlobal -m <machine>routes over the server's existing SSH/WSL links;--jsoneverywhere; implicit context fromTTY7_PANE/TTY7_WS/TTY7_SOCKET, injected into every spawned shellThe server's control dialect grew several additive verbs to carry this: a read-only
Observefor watching a pane without owning it, budgeted per observer; pane exit codes, which previously were always reportedNoneand now actually carry the code; and aggregateAgentStates/Routes/Statusqueries. 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::clientexposes theControlClient/PaneClientpair 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
attachverb — agents drive panes withrun/send/capture/eventsinstead of taking one over interactively. Anattachverb 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 previoustty7 attachto remove out from under existing scripts. The protocol-levelAttachcall the GUI's own panes use is untouched.Every unimplemented verb (
ws stop,machine connect/disconnect, baretty7launching the GUI) says so in--helprather 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
tty7immediately. It writes nothing to disk and cannot fail. The on-disk half covers typingtty7in 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 toHKCU\Environmentthrough the registry API rather thansetx, 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 aStringsurvives 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 (
InstalledShadowednames whichevertty7beats it on PATH). Debug builds and cargo build trees only get the environment half, so acargo runcan't repoint your realtty7at a binary the nextcargo cleandeletes. 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, orinstall_cli_on_path: falseinconfig.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-schemestylesheet 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 ispi --session <id>(Pi's--resumeis a boolean that only opens the interactive picker), with--session/--session-id/--fork/--resume/-r/--continue/-cstripped off the replayed launch flags so the restored id wins. A pane launched with--no-sessionis 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 whatwsl -dcalls the distro, since that string is also how the machine is keyed (wsl:<distro>), and searching the switcher forwslfinds all of them.A distribution is served the Linux
tty7-serverthis client shipped with rather than one downloaded from a release, so the first connect writes it into~/.local/share/tty7/bininside the distro — with the same one-time confirmation any other machine gets, and nosudoanywhere. A build with no bundled server (anycargo 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.exeexits, andsetsiddoes 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--stdioone, 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 sameHostcalls 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 —
Hostgrew a streaming companion to its bufferedgit, 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-alivegit diffstill 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 −Nworking-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_previewinconfig.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 answersa=qcapability 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 —
secondarymeans 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-\andctrl-spacebelong to the terminal, and window actions live onctrl-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 oldsession.jsonis 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) replacessession.jsonfor 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)
- Saved layouts do not carry over. The tree is a new file
(
-
The prompt editor's soft newline is now a rebindable action —
Shift+EnterandAlt+Enterhave 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 noInsertNewlineto name inkeybindings, 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, plainEnterstill submits the whole buffer — but it now runs through anInsertNewlineaction, 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
Enterremains the key that accepts it. AndShift+Alt+Enter, which the old modifier test caught by accident, now submits like any otherEnter: 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 HEADwas read into oneStringbefore parsing began. It is now read incrementally through a new streaming call onHost, 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 owngit 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
DiffLines. 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 −Nkeeps 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 --othersreports every path not yet ignored, and one un-ignorednode_modulesreached 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 diffin 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 −Nfigure at which it would otherwise fire on an ordinary afternoon's work.
- The full
-
One
git diffper 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-serverhad been replaced, could land on permanentlydisconnectedpanes 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 newControlHelloOk.instanceidentifies 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--resumerather 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 --resumecalls 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:Spawncarries 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 --plainreplays a pane's bytes through the samealacritty_terminalgrid 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.--plainnow 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. Rawcapture(no--plain) is unchanged: still the exact bytes the daemon stored.Separately, every CLI verb that outputs to a pipe now ends the way
catdoes 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 astty7 ls | head -1. Rust disablesSIGPIPEby default, so a doomed write surfaces as anio::Errorandprintln!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
AtomicBoolthe reader thread only flips on EOF, so right afterrestart()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 —
Tabreaches the prompt editor through aSendTabaction 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 olderCANDIDATEFORMcall 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
Untitledas 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 asWM_CLASSand Wayland matches against the packagedtty7.desktopentry, 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_ICONships raw pixels per window rather than reading the desktop entry), soassets/app-icon.pngis decoded once and downscaled to 256px — the most a taskbar uses — and attached viaWindowOptions::icon. macOS and Windows already got their icons through the bundle and the embedded.icorespectively 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.exeis 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_historyand~/.bash_historythrough 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\ndown 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
ReadDirectoryChangesWcompletion 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) — 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_hiddento 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
TitleBarhas 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.
.gitignoreedits 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.gitignorehas 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-Nnow walk history exactly like ↑ / ↓ (completion picker and reverse search included);Ctrl-Yyanks back whateverCtrl-W/Ctrl-U/Ctrl-K/Alt-Dlast 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; andAlt-.inserts the previous command's last word, repeating to walk further back. Any chord the editor has no answer for —Ctrl-T,Alt-U, abindkey-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
#528bffrather 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=tty7andTERM_PROGRAM_VERSION, the de-facto standard pair Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all set.TERMnames 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 ownTTY7marker 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. UnlikeTERMandCOLORTERM, both new variables can be overridden fromenvinconfig.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[4mspan 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 sameUnderlineStyleas 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.
cdstill filters to directories. Command position and~/still fall through, as does a foregroundsshtyped 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 tomodifyOtherKeys, which tty7 doesn't implement. That collapsed distinct chords onto the same legacy byte —Shift+Enterreached 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) — 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/linkcome from the theme's own ANSI-16 at 33 call sites — Dracula's delete button is literally the#ff5555the 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
plusbecause a bare cross looked frail, astock/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-closedgains an inner rule that distinguishes it fromfolder, andinfoa title bar that stops it colliding withpanel-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) — 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/⌘Dagainst 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.logwith 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) —
🗂️,❤️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 onlycell.creached 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, ande+ U+0301 renders asé. Narrowing (U+FE0Eon 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_terminalbug became reachable:push_keyboard_modecaps its stack by removing from the title stack, a copy-paste slip that compiles because both areVecs. 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, andcatof 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_systemno longer panics every launch on Linux — flipping "sync with system" on in Settings persists immediately, so the app then aborted on every start untilconfig.jsonwas 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'sRefCellis already mutably borrowed, and readingcx.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_jsonrejects 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-FileandSet-Content -Encoding utf8all write a BOM, so editingconfig.jsonfrom a shell was enough to lose every setting.config.json,session.json(which lost every workspace the same way) and hand-authoredthemes/*.yamlnow skip a leading BOM. (#215) -
New tabs and splits open in the right directory even when the shell can't be instrumented (#187) — a pane learned its directory from
OSC 7, which only shells tty7 injects its integration into ever emit. A shell thatexecs into another one from its rc file (exec fishat 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 emitOSC 7keeps its own spelling of the path:$PWDpreserves 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) — 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) — 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 intoESC fnever 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 thel0ng-ai/zedfork. (#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_fallbacksnamed 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 aconfig.jsonthat 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 relaunchesgrok --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 tty7and Services sit in the app menu; Docs, Discord andReport an Issueare 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 · Helpreplacestty7 · Shell · Window · View. TheShellmenu 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, andDelete 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 ofpalette.rs. (#175) - Settings re-sectioned around what you're configuring — the orphan
Shellpage folds into Terminal's first group, a newInputpage 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, soopacity,blur,completion,ctrl-randgroupingnow 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
ToggleTabSidebarhas 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 insidebar_foregroundinstead of the near-blackforeground, 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:
lsprinted 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_CTYPEonly, 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 forwardsLC_*by default. (#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/latestimmediately. The in-app update check polls exactly that endpoint, so users were prompted to download a version whose.dmgwas 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 Workspaceends one for real (no default chord — it kills sessions),Delete Workspacealso 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_searchinconfig.json. With it off, the prompt line is handed to the shell and the raw^Rfollows 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.gitignorechain 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
.rsfile 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 −ncounts. (#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.lockis checked againstCargo.tomlin 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 −Nrefreshes 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) ToggleSftpearns 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/pushdstop offering files; and the whole feature can be turned off withtab_completionin 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
/procthere), 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 —
mainis built every night into a rollingnightlyprerelease 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 ofgit statusby 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/−Ncounts — 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_sizeconfig 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→Snapshotpair 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.jsonscripts 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—Includefiles honored, wildcard patterns skipped — and hosts fromknown_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 (zshINC_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 viaansi_colors.*inconfig.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_featuresconfig passes OpenType features (e.g.{"calt": true}) through to the renderer, and Settings → Appearance grows a toggle for the commoncalt/ligapair. 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 Unixread()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 leaveZDOTDIRpointing 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 reprintingmodules/…: Installedand hanging for ~3s. Each redirector now pointsZDOTDIRback 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~/.zshenvthat setsZDOTDIR=~/.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 onPATH— 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 editingconfig.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 andPATH— and falls back to Windows PowerShell as before. Setshellinconfig.jsonto 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 onPATHas a catch-all, after the/etc/shellsentries. (#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
$SHELLinherited 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 withcheck_for_updatesinconfig.jsonor 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
ClearScrollbackinkeybindings. - ⌘⏎ toggles window fullscreen (new
ToggleFullscreenaction, 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_zoomdefault), making room for fullscreen on the bare chord. An existingToggleMaximizePaneoverride inkeybindingsstill wins.
Fixed
- Windows: launching tty7 no longer opens a stray console window behind the
app. Release builds are now linked with the
windowssubsystem; debug builds keep the console soprintln!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.exeandpwshnow emit the OSC 133 semantic-prompt marks and OSC 7 cwd that zsh/bash/fish already do, injected via-EncodedCommandafter 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 echoing0;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'sget_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 topowershell.exedirectly.
0.2.0 - 2026-07-04
Added
- Underline styles: undercurl, double, dotted, and dashed underlines render distinctly.
config.jsonhot 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-intelrunner (macos-13was 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 aVecDequeso a full ring no longer memmoves 8 MiB per ~1 KiB PTY read, and the per-connection writer coalesces queuedOutputframes (≤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 runawayyescan't grow daemon memory without bound.TTY7_TRACE=1prints 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 consecutiveOutputframes 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 SIMDmemchr; the gate's hot path is a lock-free atomic (previously a Mutex plus an unconditionalnotify_allper socket write); and the four threads on the interactive output path ask macOS forUSER_INTERACTIVEQoS to stay off the efficiency cores (TTY7_NO_QOS=1opts out).
Fixed
- A long
--config-dirpath crashed the GUI at startup ("path must be shorter than SUN_LEN"): when<config>/daemon.sockwould 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;Bmark) 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 (
lsplus 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 fromCtrl+<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
$HOMEwhen 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_terminalfork. - 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.