A first terminal that fails to spawn logged the reason and showed the
home screen — the same screen you get after closing everything. On a
first launch with a bad shell configured, tty7 opened, said nothing, and
every ⌘T after it failed the same way.
The home screen now carries the reason, and a failed New Tab leaves it
there rather than only in a toast that goes away.
The reason itself was worth fixing too. A missing shell arrived wrapped
four deep:
Could not open a terminal: daemon refused Spawn: spawn failed: Unable
to spawn /usr/local/bin/x because it doesn't exist on the filesystem or
is not executable (ENOENT: No such file or directory)
The daemon now checks a shell given as a path before anything tries to
run it, and the two internal prefixes are gone:
Could not open a terminal: no such shell on this machine:
/usr/local/bin/x
A bare program name is left alone — the OS resolves it through PATH, and
guessing there would be worse than saying nothing.
Nineteen rows in one undivided run, from identity files to warn-on-close,
in the order the form happens to build them.
They now 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, it just never said so.
Downloading the same file twice overwrote the first copy, and uploading
into the folder on screen overwrote whatever was already there. Neither
said anything, and neither is recoverable.
| | Before | After |
|---|---|---|
| Download to a name already in ~/Downloads | overwrites it | saved as `name (2).ext`, the way a browser does — still one click, and the first copy survives |
| Upload a name already in the remote folder | overwrites it | asks once, naming what it would replace (up to three, then `…`) |
The upload check reads the listing already on screen rather than making
a round trip to the far side to ask.
Recording a shortcut commits after a 650ms pause, and the hint asked
people to time something they could not see: "pause to save · Esc", with
no countdown behind it.
A two-pixel bar under the hint now runs the same clock the commit does,
and restarts with every extra chord — so a key sequence reads as one
gesture with a visible deadline instead of a guess.
With text in the search box, ← and → belong to the caret and Tab becomes
the way between the two columns. That remap is deliberate — the code says
so — and it was also silent: the arrows simply stopped crossing, with
nothing on screen to explain it.
The footer hint, which already changes while the switcher is held open,
now says "Tab to cross columns" whenever a query is present.
Closing a tab is the highest-frequency destructive key in any terminal,
and tty7's headline claim is that shells outlive the app — yet ⌘W was
the one action that permanently ended one, silently. A mid-turn coding
agent, a forty-minute build and an idle prompt all died identically, and
Reopen Closed Tab restores the layout with a *fresh* shell, so the undo
was never an undo.
The SSH close guard already had the shape for this. It is now a general
close guard: a pane reports whether it is busy, and closing asks once,
naming what it would end.
| | Before | After |
|---|---|---|
| ⌘W at an idle prompt | closes | closes, unchanged |
| ⌘W with `cargo build` running | kills it silently | "Close this tab? — cargo build is still running. Closing ends it." Keep is the default button |
| ⌘W with an agent mid-turn | kills it silently | names the agent and its turn |
| Close Other Tabs / Tabs to the Right | skipped live-SSH tabs | skips anything that would raise a question, rather than stacking one dialog per tab |
A pane counts as busy only when the shell is actually reporting: without
shell integration `at_prompt` is permanently false, so a terminal that
trusted it would ask this question on every single close. Agents report
independently and are trusted either way.
- The SSH close confirmation was the app's only bespoke in-app dialog:
no scrim, no Escape, no click-outside, no focus — the two buttons were
the only way out, and an on_key_down there could never have fired
because nothing inside the card is focusable. It now asks through the
same platform prompt as the other eleven confirmations, which gets
Escape and dismissal from the OS.
- The switcher's tab column reported "No workspace or machine matches."
That is the other column's noun.
- ja left "bind" and "to" in English inside a form whose other labels are
Japanese; zh had already translated both.
- The bind and target host fields in an SSH forward rule were pinned at
104px, which does not hold an EC2 internal hostname. Same floor, but
they now take a share of the row's slack instead of handing all of it
to the free-text description beside them.
- docs/features.md still described ⌃⇥ as "next tab"; since the two-column
switcher landed it holds a panel open and commits on release. Also
drops two more names for actions the app calls Zoom Pane and Clear
Scrollback.
Reconnecting to several machines whose servers are all behind 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 now walked one question at a time: the next is asked only
after the previous is answered. If the window goes away with a question
open, the rest go back on the queue instead of being swallowed.
The parity guard walked a hand-maintained list of 900 keys. A key added
without being added there was checked by nobody, and the list had drifted
— that is exactly how eleven values ended up shipping as their English
originals in zh and ja.
The enum is now emitted by a macro that also emits L10nKey::ALL, so there
is one list and the compiler reads it. The guard sweeps ALL and, on top
of the non-empty checks it already made, fails on a translation that is
byte-identical to the English. What that must not catch is spelled out
in KEPT_IN_ENGLISH with a reason each: protocol names, pure templates,
product names, a language named in its own language, and the
shell / agent / ssh set that would read worse half-translated.
Also translates what the new check found and should not have kept:
ja "bind" / "to" in a form whose other labels are Japanese (zh had
already done both), and Stable / Nightly in zh and ja.
Net −850 lines.
Editing an SSH profile and pressing Escape closed Settings and discarded
the form. `dirty` was already computed — it is what enables Save — but
only to grey out a button, never to ask.
Escape, the close button and ⌘, now ask when the form holds unsaved
edits, with Keep Editing as the default. Every other caller of
close_settings runs as the tail of something the user explicitly chose
(Connect, Save & Connect, quick connect) and closes as before.
The auth loop seeded its reason with "authentication failed" and only
replaced it when some method actually reported one. So a round where
every method was skipped — no key on disk, no agent, or a connection
pinned to a method this server does not offer — came out as a failure,
which sends people looking for a wrong password that was never sent.
That case now says what it is and names what the server would accept:
"no authentication method could be tried; the server offers publickey".
The disconnected strip also gained the two things it was missing. It
reported only that the connection had ended, so a rejected key and a
dropped network read identically and the reason scrolled away with the
pane's own output; it now shows the reason in the danger ink. And for a
pane spawned from a saved connection it offers "Edit connection…"
beside Reconnect — until now the only button was Try Again, on the one
error class where trying again unchanged never helps.
Tested: unit cover for the no-attempt message (names the offered
methods, never says "failed", and does not trail an empty list when the
server offered nothing). The strip itself was not seen on screen — this
machine has no sshd to fail against, and I did not want to authenticate
against someone else's.
The page the docs call *the* shortcut reference rendered 89 undivided
rows of humanize_action's CamelCase split — English in a three-locale
app, and a fourth vocabulary on top of the menu bar, the palette and the
docs: "Toggle Maximize Pane" for what everything else calls Zoom Pane,
"Close Active Tab" for Close Pane / Tab, "Toggle Palette" for Command
Palette.
Adds keymap::action_entry, which routes every action's name and section
through the strings the rest of the app already uses. The list is now
read in the same seven sections the command palette groups by, and the
displaced-binding notice uses the same names as the rows it is talking
about.
Seven new keys across en/zh/ja; the nine tab and nine workspace jumps
are templated rather than eighteen hand-written strings per locale.
every_action_has_an_authored_name fails if a new action would fall back
to the CamelCase split, so the page cannot drift back one action at a
time.
The local file tree asks before it deletes a file. SFTP delete is the
same red Delete in the same shape of menu, on a machine you cannot walk
over to, and it went straight through — the asymmetry was the tell.
- SFTP delete now confirms, naming the file and the host, and says
plainly that there is no trash on the far side.
- Deleting an SSH profile confirms. It also stops orphaning the profile's
keychain entry: "Forget password" lives on the menu that is about to
stop existing, so the secret had no UI left to remove it. The secret is
only let go when no other profile still points at the same endpoint,
and the copy says so.
- "Restore all defaults" wipes every rebound key with no undo, and now
asks. It is a no-op when nothing has been rebound, so it does not ask
a question with no stakes.
Deliberately left alone: Clear Scrollback (every terminal clears without
asking) and killing a port forward (cheap to recreate).
Working, Needs input and Done were a hue on a nine-pixel dot with no
shape, label or tooltip anywhere. The pair that decides whether you go
and look — Needs input vs Done — is amber vs green, which is the pair
red-green colour vision separates worst.
- Needs input now draws as a ring rather than a filled dot, so it
differs in shape and not only in colour.
- The tab avatar carries a tooltip naming the agent and its state, in
all three locales. It had no tooltip at all before, on any of its
three call sites.
- Codex and Grok both ship a pure black disc, which is the window fill
on a dark theme: the disc dissolved and left a white glyph floating.
A disc whose brand colour lands within 1.25:1 of the window now gets a
hairline, which also covers user themes rather than one palette.
Tested: unit cover for the status labels (every state that paints a dot
has words, and Needs input never reads as Done) and for the disc edge
rule. The avatar itself was smoke-checked in a dev instance on a shell
tab; the agent branch was not exercised on screen — no agent was run.
read_dir was unwrap_or_default()ed, so a folder the OS refused produced
an empty listing and rendered byte-identically to one that really is
empty — and to one still being listed. The file tree had no error field
at all, and its InFlight set was never read by render.
An expanded directory with no visible children now draws a placeholder
row saying which of the four it is: Reading…, Empty, Only hidden files
(entries landed but the hidden filter took them all — "empty" there is a
lie one keystroke disproves), or Could not be read, in the danger ink.
Placeholders are not files: arrow-key navigation skips them.
Also gives the tree column itself a state — a search that matches
nothing, and a tab with no directory behind it, both used to render as
the same empty column — and the tab sidebar a note when a filter drops
every group.
The zero-query list pushed a Recent section and then pushed every group
that contained those same commands, so the five rows you use most were
the five rows the list showed twice. Promoting to Recent now moves a
command rather than cloning it, and a group left empty by that drops its
header with it.
Ranking also threw frecency away the moment a character was typed —
ordered by usage at zero query, ordered purely by fuzzy score after
that, so the command someone runs every day stopped floating exactly
when they started reaching for it. A bounded log bonus (0..=24, against
16 points per matched character) now 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.
gpui-component renders an icon-only .xsmall() button as a 20x20 box, and
two sites overrode a 32px chrome tile down to 18x18. WCAG 2.2 SC 2.5.8
puts the desktop floor at 24x24, and the tab close button — the one that
ends a shell — was among the smallest.
Adds tab_strip::hit_target, which grows only the box. The glyph keeps
its size, so the chrome is visually unchanged; the tab chip's close
button moves from top: 5 to top: 3 so it stays centred in the 30px chip.
Applied to: tab chip close, sidebar row close, managed forward delete,
SFTP tray dismiss, SFTP reveal-download, SSH forward-rule delete, and
both switcher overflow menus.
The search field computed per-section match counts and rendered them as
"Appearance (2)" badges, then left you to find those two rows among
sixteen. Nothing was ever filtered, highlighted or scrolled to.
- A row whose label or indexed keywords match the query now renders at
full strength on a tinted fill; the rest of the section dims. Marking
only happens when the section actually holds a match, so a query that
landed elsewhere never greys out a page being read.
- A query that matches nothing anywhere says so, instead of leaving the
page looking as if the search did nothing.
- Adds the "How shells work" block the index has pointed at since it was
written. The entry routed to About, and About never mentioned
persistence — the one thing that separates tty7 from any other
terminal was not stated anywhere in the app. It now sits directly
under the app identity, above Updates.
Both cursor renderers painted a block at ~50% alpha over the finished
cell, so a caret the theme conditioned to 3:1 landed on screen at
roughly 1.9:1. Bar and underline carets were translucent too, for no
reason at all — they are thin marks that cannot occlude a glyph.
Every other terminal (Alacritty, WezTerm, iTerm2, Ghostty,
Terminal.app) paints an opaque block and redraws the character on top
of it. Do the same:
- element.rs inverts the cursor cell in the render buffer before the
backgrounds go down, so the caret rides the normal
background-then-glyph path; paint_cursor's focused block arm is now
empty, and the unfocused outline is opaque.
- view.rs does the same for the shell input editor's own caret, which
is the one users actually see at a prompt.
- The glyph is redrawn in whichever of the terminal's background or
foreground reads better on the caret, via a shared presets::caret_ink.
Review catch on #400: border is handed to sidebar_border and drawn on
popover chrome, but was floored against the window background alone —
a narrower guarantee than the one this PR gives sidebar_fg and the
semantic inks.
The terminal's own context menu was the last surface still speaking
hardcoded English in a three-locale app — 14 literals, while the tab and
switcher menus route every item through t(). The search bar had three
more, and its Previous / Next / Close buttons were icon-only with no
tooltip at all.
It also spoke a fourth vocabulary for one action: "Maximize Pane" for
ToggleMaximizePane, which the menu bar, the palette and Keybindings all
call "Zoom Pane". "Close Pane" is likewise "Close Pane / Tab"
everywhere else, and it is the accurate name — the action closes the
tab when the pane is the last one.
Adds AppMenuSplitLeft / AppMenuSplitUp and SearchFind / SearchMatchCase
/ SearchUseRegex across en, zh and ja. The fork submenu now takes its
title from the locale table instead of tty7-core's fork_label(), which
is a capability probe in a crate that has no locale table.
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. Semantic inks were floored against the window background but
painted on popovers and sidebar rows, which sit a step toward the
foreground.
- sidebar_fg is now floored at 4.5:1 on the sidebar fill it is painted
on, not on the background. Four builtins landed it at 3.35-3.92:1.
- caret is conditioned to 3:1. The default Light theme shipped an
orange caret on pure white at 2.07:1.
- border keeps its blend but gets a 1.5:1 floor so a divider is worth
the same in every theme, instead of ranging 1.26-1.64:1.
- semantic inks and fills clear their floor on background, sidebar and
popover, not just the background.
Themes that already cleared a floor are untouched.
Staging a pasted screenshot to a file and handing the agent its path was
built off macOS only. A local macOS agent reads the system clipboard itself
when it sees SYN, and that carries the image at full fidelity, so the
platform kept SYN and skipped staging entirely.
That 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 SYN was a no-op and pasting did nothing
at all. Remote panes now stage and upload on every platform, decided by one
predicate instead of a platform cfg over the whole feature; a local macOS
pane still forwards SYN untouched. macOS screenshots reach the pasteboard as
TIFF, which agent vision rejects the same way it rejects a Windows BMP, so
those transcode to PNG on the way out.
A WSL pane was broken for a different reason: it shares this machine's disk
but not its path syntax, so it needs no upload, only a rewrite -- and there
was none. The agent got C:\Users\...\paste-1.png and found nothing there.
The paste now carries the automount view, /mnt/c/Users/... A path with no
mapping, such as a UNC temp directory, keeps the Windows name, which at
least says where the file went.
Seven pre-existing tests for this feature were compiled out on macOS by the
same cfg and now build and pass there. Three were added: the staging
predicate, the WSL rewrite (including UNC and drive-relative refusals), and
the TIFF-to-PNG transcode, whose failure mode is otherwise a silent no-op.
Both handshakes compare their version for equality and hang up on
anything else — the pane protocol in `ensure_running`, the control
dialect in `host::server`'s hello. So a server whose number disagrees
cannot be talked round, and the "Keep Shells" 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. Taking that button was indistinguishable from the bug it
sat next to.
Restart or quit, then. Quitting is the half that destroys nothing — the
server and every shell under it keep running — which makes it a real
answer for someone who would rather go install the matching build than
lose a session mid-flight. Quit goes first, the way every other
destructive prompt here is ordered: NSAlert and TaskDialog give the
first button Return, and this prompt arrives unasked at launch, the
moment a stray Return is likeliest, so the reflex key has to be the one
that destroys nothing. Restarting stays a click or an arrow away.
A prompt dismissed without an answer re-arms instead of falling through:
`take_mismatched_daemon` takes, so without this a closed window is a
back door into the state this prompt exists to prevent.
The copy is rewritten to one line of diagnosis and one line per button,
in all three locales, listed in button order. It was a paragraph that
had to be read twice to find out what the buttons did.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
A re-transmitting sender like terminal-browser sends a fresh full-window
frame per rendered frame — ~26 MiB of RGBA at Retina resolution. On the
client that buffer was copied twice for no reason on the way to the atlas:
`decode_frame` allocated a new Vec for the payload tail behind the 30-byte
header, and the uncompressed path of `to_rgba8` then cloned it again before
the in-place BGRA swap.
Thread ownership through instead:
- `Image::decode_frame_owned` consumes the frame Vec the reader already owns
off the socket and drains the header off the front, reusing that allocation
as the pixel buffer rather than allocating and copying a fresh one.
- `Image::take_rgba8` moves the pixel buffer out on the uncompressed fast
path (the shm/file transport hands us pixels already in `f=32` layout), so
`decode` swaps R<->B in place with no clone. The compressed inflate, the
PNG guard, and the declared-dimension inflate bound are unchanged; `f=24`
still repacks because RGB->RGBA changes the length.
Removes two ~26 MiB per-frame touches on the client hot path. On a 3216x2160
frame the decode+normalize step drops from ~1.78 ms to ~0.89 ms — ~0.9 ms
saved per frame, ~53 ms/s at 60fps. This does not touch the wire frame layout
or the daemon-side transfer; it is a pure client-side allocation cut.
The fork job never checked where the head branch lived. It was written
as the fork path and reads like one, but `pull_request_target` fires on
every pull request, so labelling one of ours landed there too --
silently, and with the weaker review: no plugin, no whole-repo context,
read-only tools. #389 got that instead of the review it should have had.
Also corrects the `labeled` comment in claude-code-review.yml, which
promised exactly the case that cannot work. A `pull_request` workflow is
read from the PR's merge ref, and GitHub recomputes that on a push and
not otherwise, so a PR whose last push predates the file has a merge ref
without it and no label can summon it. Pushing fixes it, and also
triggers synchronize by itself -- which is why the empty commit worked
and the label looked broken.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Two changes to the same job.
Findings now go inline. The action ships an inline-comment MCP tool
that buffers rather than posts: the queue is sent after the session
ends, by the action's own code, past a classifier. That keeps the
property review.md was chosen for -- the reviewer writes the words, a
step that cannot be argued with performs the act -- while putting a
finding next to the line it is about. review.md stays for the summary,
which belongs to the change as a whole.
Reads are fenced. A comment body is posted verbatim, so any file the
reviewer can read it can publish, and the token is in this process's
environment; /proc is the short path between the two. Reads are already
confined to the working directory in the default permission mode, but
this job should not rest on a default. Deny is evaluated before allow,
so the rules hold whatever --allowedTools says.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The label was a one-shot trigger, so a contributor who addressed the
findings and pushed got no second look unless someone removed and
reapplied it. It now reads as a subscription: `synchronize` re-reviews
while the label is present, and taking it off stops that.
Two clauses rather than one label test, because `labeled` carries the
label that was applied and `synchronize` carries none -- testing only
the list would spend a review every time an unrelated label landed on a
subscribed PR.
This does not gate the code in the next push, and the header comment now
says so. The gate is spend and attention; the safety is the base-branch
workspace root, the absent Bash, and executing nothing.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The general instruction was one line -- logic errors, edge cases,
regressions -- followed by a numbered list of four, three of them
tty7-specific. A list anchors, and that one named none of what actually
breaks a terminal emulator: panic paths, unsafe, ordering, resources on
the failure path, an API contract that no longer holds for its new
callers.
Replacing it with a longer list would only move the boundary. So the
prompt now states the aim, offers examples while saying outright they
are not a checklist, and notes that the finding nobody listed is
usually the one worth having. The repo rules stay, demoted to what an
outside reader cannot know and explicitly skippable.
claude-code-review.yml is left alone: its rules are appended to the
code-review plugin, which brings its own methodology, so a general
clause there would compete rather than add.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
track_progress only fires on opened, synchronize, ready_for_review and
reopened. The approval gate depends on `labeled`, which is none of
them, so the action refused the run outright rather than falling back
to the log.
The review is written to review.md and posted by a final step that runs
no model and reads one file. That adds Write to the allowlist, which
costs nothing: there is still no Bash, so still no curl, and the runner
is discarded after the comment goes out.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>