Commit Graph
792 Commits
Author SHA1 Message Date
l0ng-ai 005efee058 fix(ui): explain the io errors that build their own message
`explain_io` exists because "Permission denied (os error 13)" answers a
developer's question and not the reader's — its own doc says so. Every
failure routed through `HostOps::notify_err` gets it. Four that build
their notification string by hand did not, and printed the raw error.

The clearest symptom was inside one file: saving a file in the editor
went through `notify_err` and explained itself, while opening the same
file, denied for the same reason, said `os error 13`. Both file-link
openers are changed together — the file tree's opener deliberately shares
its wording with the terminal's (#542), so explaining one and not the
other would have split a pairing that was on purpose.

The `log::warn!` next to each call keeps the exact error. A developer
reading a log and a person who just lost a save want different things,
so both are written rather than one chosen — noted on `explain_io` along
with the rule, since it was the absence of a stated rule that let four
callers drift.
2026-08-16 00:52:02 +08:00
l0ng-ai 4a41dbd747 fix(ui): cut display text between clusters, not inside them
`tab_strip::clusters` already writes down why a label may only be cut on
grapheme boundaries: `👨‍👩‍👧` loses the joiner holding it together, `❤️`
loses the variation selector that makes it an emoji, and `🇨🇳` leaves a
lone regional indicator that renders as a bare letter. Three other
functions cut display text by `char` and so do exactly that.

`elide_middle` is the one that shows the gap most clearly. It documents
its cuts as safe because "everything here walks `chars()`, never bytes" —
true, and it guards the hazard that produces invalid UTF-8, which is not
the hazard that reaches the screen. Its sibling test is named
`never_cuts_a_multibyte_char_in_half`; the cluster is the unit above that
one, and nothing was holding it.

Each function keeps its own algorithm — head cut, middle cut, ellipsis
budget — and only the unit it counts in changes. That also makes the
budgets more honest than they were: a budget in clusters is a budget in
what actually gets drawn, where a budget in `char`s let one flag spend
two of it.

Callers pass ASCII in every existing test, so no rendered string that was
already correct changes.
2026-08-16 00:46:02 +08:00
l0ng-ai 8e4de5d525 fix(ui): route every home lookup through the one that knows Windows
`path_display` opens by calling itself the one place a path is measured
against `~`, because three rows spelling that check their own way is what
#544 was. Three sites in src/ui had since gone back to reading the
environment directly, and two of them read only `HOME` — the variable
#544 recorded as "often unset" on Windows:

- the file tree's fallback root, so a Windows window with no resolved
  root drew an empty tree instead of the home directory;
- `expand_path`, so a `~/`-prefixed theme in config fell through to
  `themes_dir().join("~/theme.json")` — a path that cannot exist, and no
  error to say why.

The third (the SFTP download directory) already checked `USERPROFILE`,
but as a second copy of the canonical lookup under the same name, minus
its empty-string filter: `HOME=` set-but-empty resolved downloads to a
relative `Downloads`. It keeps its `.` last resort, which is a real
difference worth stating rather than deleting — a download has to be
offered somewhere, while every other caller wants the `None`.

No behaviour change on Unix, where `HOME` was already the answer.
2026-08-16 00:40:19 +08:00
l0ng-ai 25d1f06a57 fix(file-tree): fold a filename's line breaks where it is drawn
A filename is bytes to the kernel, so `touch $'a\nb'` makes a file the
tree has to draw, and gpui breaks text on `\n` whatever the row says.
The row grows to two lines, its `text_ellipsis` contract stops holding,
and the rows below it slide down — one odd file misaligns the column.

The terminal's history rows already met this and already carry the fold
(`one_line`), so reuse it rather than spell a second one. What differs
here is where it may be applied: a history entry is only ever drawn, but
a filename is also handed to `join`, `rename` and `remove`. Folding at
ingestion would splice a `↵` into a name that has to match what is on
disk, so the fold sits at the two `.child(...)` sites and nothing on the
operations path sees it.
2026-08-16 00:36:40 +08:00
l0ng-ai 2919464e84 docs(pane): why the split ratio's clamp is enough, and when it stops being
`f32::clamp` returns NaN for a NaN input rather than rejecting it, so the
clamp guarding a stored split ratio is weaker than it reads: a NaN would be
kept and then multiplied through every size beneath it.

Nothing can supply one today, but establishing that meant reading three
files -- the machine tree refuses a non-finite ratio (`clamp_ratio`), a
session file cannot carry a NaN because JSON has no such literal and an
out-of-range number arrives as an infinity that clamps like any other large
value, and both places that compute a ratio are guarded (`set_run_shares`
divides only `if total > 0.`, which is false for NaN too; `resize_focused`
adds a caller's constant).

Written down at the line that would have to change, because the thing that
makes it safe is somewhere else entirely, and the daemon already spells the
same rule out with an explicit `is_finite`. No behaviour change: adding a
branch for an input that cannot arrive would be the other mistake.
2026-08-16 00:17:18 +08:00
l0ng-ai 74d647e5ff refactor(tree_sync): make the sweep's decision assertable
Ending a pane was one function: work out which parked panes nothing holds,
then hang them up. Only the second half needs a running daemon, and mixing
them meant the rule could only be checked by watching a fuzz run --  which
swings from fifty operations to five between runs on the same seed. That is
how a change of mine looked effective for five iterations while doing
nothing at all.

`stranded_of` is now the rule on its own, and a test states it directly: a
pane on screen is never ended whatever the tree says, a pane the tree still
names is never ended whatever the window shows, and only one held by neither
goes. Each of those three has been the difference between a leaked shell and
a killed one at some point in this file's history.

No behaviour change -- same filter, same order, called from the same place.
Verified the test earns its keep: dropping the `showing` check from the
filter fails it with `left: [1, 4], right: [4]`.
2026-08-16 00:07:58 +08:00
l0ng-ai 976bd36566 fix(tree_sync): ask every window before ending a pane, not just one
Both hang-up paths guarded on "the window that raised this is not showing
the pane". The answer they are guarding is the daemon's, and the daemon's is
machine-wide: `collect_orphan_panes` names any registered pane no workspace
holds, whichever window spawned it.

So with two windows on one machine, the second could spawn a pane, have it
registered, and still have its TabCreate queued -- and a close raised by the
first would see it named as detached, find the *first* window is not showing
it, and end it. A pane the other window is about to draw.

The same mistake as judging a pane against a tree that has not caught up,
one window over. Now the guard is the union over every open window, and a
window that will not answer abandons the sweep rather than shrinking it: one
that cannot be read might be showing anything.

Found by re-reading my own two commits rather than by a failure -- the fuzz
that verified them drives a single window, so it could not have caught this.
2026-08-15 23:02:04 +08:00
l0ng-ai a689ee2bb2 refactor: say why the clippy allows are there, and drop the one that is not
Sixteen `#[allow(clippy::…)]` in the tree; thirteen carry a comment saying
why. These were the three that did not, and the reason turned out to differ
for each.

`spawn_once` takes eight arguments against a threshold of nine, so its
`too_many_arguments` allowed nothing at all -- removed. `render_ssh_row` does
trip it, at ten with `self`, and `LocalHost::new` really does hand back a
`SharedHost` rather than a `Self`; both now say so.

Removing all three first and reading what came back is what separated them,
and it needed two passes: clippy stops at the first crate that fails, so
tty7-core's error hid settings.rs's until it was fixed. A single clean run is
not evidence about anything downstream of the first failure.
2026-08-15 22:54:11 +08:00
l0ng-ai 489c394837 fix(tree_sync): end the shells a layout rewrite leaves behind
The window drops a view whenever its layout is rewritten -- a delta arriving
for a tab it had built differently, a rebuild from the tree -- and dropping a
view does not end the shell behind it, because that is also what detaching
is. So every reshape left panes running that nothing could reach again. This
is where most of them came from: a 150-op fuzz against an open window shows
31 layout rewrites to 2 rebuilds and 12 refused ops.

Panes put down this way are *parked*, not ended, and judged later. At the
moment of the drop neither mirror can say whether a pane is still somebody's:
the machine copy is missing whatever this window has just done, since a
client is left out of the deltas its own ops raise (#612), and this
workspace's copy runs ahead of or behind ops still in flight. Judging there
does end live panes -- both a machine-mirror check and a two-mirror check
were tried, and the fuzz caught each of them killing panes the tree still
named. The parked set is swept only against a tree the window has just
pulled, which is the one account of the machine here not assembled from
deltas, and only for panes the window is also not showing. Anything that
came back into the tree or onto the screen meanwhile is forgotten rather
than ended, because that is exactly what the mirrors were wrong about.

Over six fuzz seeds: no run ends a pane the tree still names, and where a
pull lands during the run the stranded shells go from 15-26 to 0-2. A run
with no pull sweeps nothing and leaks as before -- the parking is correct,
the coverage is only as good as how often the window pulls -- and
`tty7 pane close --orphans` still reclaims whatever is left.
2026-08-15 21:40:26 +08:00
l0ng-ai 1b75500b88 fix(tree_sync): hang up the panes the tree hands back
TabClose and PaneClose answer with the panes the tree let go of, and the
shells behind them keep running until somebody hangs them up. The daemon
will not: the same removal is how a pane crosses to another tab, so it
cannot tell an ending from a move, and whoever sent the op has to say.

`tty7 pane close` always has -- hang_up_removed_panes exists for exactly
this. The window did it only where a *person* closed something, and never
for the identical ops its own reconciliation raises, because `pump` sent the
batch with `if let Err(e) = client.call(op)` and dropped the Ok on the
floor. Every pane the window put down while squaring its layout with the
tree left a live shell nothing could reach.

Measured with a 150-op tree fuzz driving the CLI against an open window:
5-6 shells per run were being stranded this way, and are not any more.
It is not the whole leak -- around 14 orphans per run survive, from the
paths ITER 13 named (a superseded hydration, a delta that would not apply),
which strand panes without sending an op at all.

A pane the window is showing is never hung up, whatever the tree says. The
daemon computes its answer with collect_orphan_panes across the whole
machine *after* the change, so a pane that merely moved is never named --
but a pane this window has spawned and registered and not yet placed in the
tree is, because at that instant nothing in the tree holds it, and its
TabCreate may still be queued behind the op that produced the answer.
Ending that one takes down a pane the user is looking at (#628). If the
window cannot be read at all, nothing is hung up: a leaked shell is
recoverable with `tty7 pane close --orphans`, a shell ended under a live
window is not.

The check runs on the main thread after the batch returns rather than
beside the calls, because `pump` may be running inside an App that already
holds the window entity, and gpui answers that by silently dropping the
update -- which stops the window syncing at all.
2026-08-15 21:23:31 +08:00
l0ng-ai 24a0469ca5 fix(ui): git's confirmation dialogs lost Escape outside English
gpui picks a prompt button's role by matching the English word -- "cancel"
becomes a cancel answer, anything else becomes a plain Other that answers
neither Escape nor Return. `confirm_answers` exists to say which is which
instead, and sixteen of the app's dialogs use it.

The two SCM confirmations built their answers by hand from localized
strings, so `t(Cancel)` came out as a real cancel answer in English and as
an Other in Chinese and Japanese: discard, reset and amend -- the dialogs
guarding the operations that lose work -- had nothing on Escape for those
users. They also passed Cancel as answer 0, which gpui draws rightmost and
gives Return, so the position that means "do it" everywhere else in the app
meant "don't" here.

Both now go through the helper, with the index check flipped to Ok(0) to
match. The single-button restart-failed dialog had the same problem from the
other side: t(Ok) is "确定" in Chinese, so its only button was not an Ok
button either.

Add a test for the underlying rule rather than the three call sites: it
pins that a localized cancel label does not become a cancel answer on its
own, and says so in the failure message if gpui ever starts reading roles
another way.

app.rs's server-mismatch prompt still builds its answers by hand, and stays
that way -- it is documented as having no safe answer to give Escape to.
2026-08-15 20:35:50 +08:00
l0ng-ai deb070f25b refactor(scm): match the reset mode the confirmation text is written for
confirm_question returned the hard-reset question for GitOp::Reset in any
mode, and that text ends "and uncommitted changes are discarded" -- true of
--hard, untrue of --soft and --mixed, which both keep the worktree.

It is right today only because GitOp::destructive classifies soft and mixed
as harmless, so they never reach a confirmation at all. That invariant lives
in another crate and nothing here said so, leaving a reader to go and check
before they could tell whether a mixed reset shows a warning about losing
work it does not lose.

No behaviour change -- the arm is unreachable for the other two modes either
way. It now states its own precondition instead of borrowing one.

confirm_verb keeps matching every mode on purpose, with a note saying why:
"Reset" is the honest button label whichever mode it is.
2026-08-15 20:29:19 +08:00
l0ng-ai ce35dca704 fix(security): redact three more secrets from Debug output
The codebase already decided a secret must not reach a formatter --
NativeSshSpec spells its Debug out by hand, AuthResponse prints
Secret(<redacted>), and secrets_are_redacted_in_debug_output pins both.
Three types carrying secrets were missed and still derived Debug.

  - KeychainWrite::SetPassword/SetKeyPassphrase hold the plaintext the user
    just typed. password_submit returns one of these *beside* an
    AuthResponse built from the same String, so the identical secret was
    redacted in one return value and printable in the other.
  - Attachment::token is the proof a connection holds a workspace. Its own
    doc says it "goes over no wire and onto no disk" and #[serde(skip)]
    enforces that -- but the log file is disk too, and the token is reached
    by the derived Debug of Workspace, Machine, and the whole tree.
  - ControlHello::client_token is the same kind of proof, in the one module
    that already logs protocol values with {event:?}.

None of the three is formatted anywhere today, so nothing was leaking; this
closes the gap while that is still true, since a single {:?} added later
would have written a password or a capability token to the log.

Each gets a test in the shape of the existing one. Verified non-vacuous:
putting the derive back on Attachment fails with
  token leaked: Attachment { token: "s3cr3t-capability", ... }
2026-08-15 20:21:09 +08:00
l0ng-ai bb563505ad refactor: scope the dead_code allows to the items that need them
Five modules carried #[allow(dead_code)] on the `mod` line itself, which
silences the lint for everything inside them -- for good. host_ops and
host_registry are live infrastructure (73 and 22 references between them);
one unused item each had switched the lint off for the whole file, so
anything that died there later would never be reported.

Two of the five were covering nothing at all: tty7-core's core::keychain
and core::ssh_profile have no dead items.

What the allows were actually hiding, and what happened to it:
  - HostOps::run_or_notify, HostRegistry::local and HostRegistry::len are
    unused everywhere, tests included -- deleted. run_or_notify was only
    run_in composed with notify_err, both of which stay.
  - InFlight::len and ByHost::len are used, but only by tests, so they take
    the house #[cfg_attr(not(test), allow(dead_code))].
  - status_rank is used only by the test that guards it against DecoStatus's
    Ord, which its doc comment already explains. Same treatment.

The two `len`s are why this is worth doing carefully: --all-targets still
reports them as never used, because the warning comes from the non-test
build of the binary. Deleting on that evidence broke the test build. The
compiler, not the lint, settled which ones were really dead.
2026-08-15 20:10:21 +08:00
l0ng-ai b11ad35680 refactor: drop eight #[allow(dead_code)] that no longer allow anything
Each of these sits on an item the code does use: ssh_config's
import_profiles, merge_imported and jump_alias, SshConnection's key field
and key(), NativeSshSpec::without_secrets, CmdEditor::cursor, and
Pane::ssh_connection. Verified by removing every dead_code allow in the
workspace and reading back what the compiler then reported -- none of
these appeared, in either a production-only or an all-targets build.

A stale allow is worse than no allow. It reads as "this is dead, on
purpose", so the next person leaves it alone, and it goes on covering the
item after the code around it changes -- at which point the item really can
die without anyone hearing about it.

cursor_byte is the one that is genuinely unused outside tests, so it moves
to the house idiom #[cfg_attr(not(test), allow(dead_code))], which still
reports it if the tests stop using it too.
2026-08-15 20:06:13 +08:00
l0ng-ai f7130c99de fix(i18n): say which request the server answered unexpectedly
"the server answered a machine tree with {reply}" reads as though a machine
tree was the thing answered. It is the request that was for the machine
tree, and the reply is whatever came back instead.

The Chinese translator had already worked this out and supplied the missing
word -- "回复了机器树请求" says "replied to the machine-tree request" -- so
zh needed no change. English and Japanese carried the ambiguity.
2026-08-15 20:02:10 +08:00
l0ng-ai bc835fd811 fix(i18n): the UI names the background process "server", not "daemon"
"daemon" is the code's word for it -- the --daemon flag, daemon/server.rs,
the RemoteDaemon* keys -- and the UI deliberately uses "server": the
dialogs say "Restart Server?" and "Quit and Stop Server?", the settings
section is headed "Server", and the errors either side of the one fixed
here say "tty7's local server".

RemoteDaemonTooOld broke that in all three languages. It called the local
process a daemon while naming the far-end one a server in the same
sentence, so the two read as different components and the instruction
"Quit tty7 (which stops the daemon)" sent the user looking for something
the rest of the app never names. It now says "tty7's local server",
matching the two keys directly above it.

The ja settings heading said "デーモンサーバー" where en and zh both say
just "Server" -- mod.rs already documents that this heading is meant to be
that word on its own.

Add a test: no user-facing string may call it a daemon in any of the three
languages. The search-keyword lists are skipped, since they carry the word
on purpose to match what a user might type.
2026-08-15 19:56:51 +08:00
l0ng-ai ceb0f76193 fix(i18n): stop calling a pane a panel in Chinese
tty7 calls the split regions inside a tab panes and the docked regions
around them panels, and Chinese has a separate word for each -- 窗格 and
面板. Eleven strings across four keys used 面板 for a pane, so they named
a different part of the window rather than reading as a loose synonym.

The settings page showed both senses a few rows apart: "text size ...
tabs, panels and settings" is genuinely 面板, while "give each pane its
own shell history" right below it claimed panels have their own shell
history -- they have no shell at all. The proxy description, the stale
daemon notice and the restart-server body had the same swap.

Japanese was already consistent (ペイン / パネル), and the English is the
source, so only zh.rs changes.

Add a test over every key: when the English names one sense and not the
other, the translation may not carry the wrong word. It matches whole
words, since "panel" contains "pane".
2026-08-15 19:52:05 +08:00
l0ng-ai d76ed6df50 diag(tree-sync): correct what the strand-a-pane note claims, after measuring it
The note said refused tree operations are how this window strands a shell.
They are one way, and the minority one — which matters, because it is the way
that would attract a fix.

Measured with a randomized tree fuzz over the CLI (70 operations: tab new,
split, pane close, tab close, tab move, invariants checked after each):

  daemon only, no GUI     0 orphaned shells
  same run, window open   16 orphaned shells, 12 panes actually held either way

A second run of 70 left 10 orphans, and the warning this note sits on fired for
3 of them. The other 7 were spawned for a layout the window then threw away
without sending an operation at all — the log shows "dropping a superseded
hydration" and a delta that "did not apply cleanly" forcing a re-pull. Nothing
is refused on those paths, so nothing reaches this arm.

They are live `zsh` processes, not bookkeeping: each orphan answers
`tty7 procs` with a pid.

So the note now says which share this path accounts for, and that the sweep has
to be phrased against the end state — a pane this window spawned that neither
the machine tree nor any live view holds is stranded however it got there —
rather than against any single failure, which would fix a third of it.

Still diagnosis only, for the reason already recorded: the sweep belongs after
a hydration settles, and verifying it needs a GUI this environment cannot
watch.

2949 tests pass.
2026-08-15 18:22:59 +08:00
l0ng-ai 84124a57a2 fix(terminal): pin the OSC 52 policy instead of inheriting "no clipboard reads"
tty7 has a `ClipboardLoad` arm that reads the system clipboard and writes it
back down the PTY, and nothing in this crate says that must not happen. It does
not happen — `alacritty_terminal` refuses a paste request before it becomes an
event, because `Config::osc52` defaults to `OnlyCopy` — but that is the VT
crate's decision, taken by a field tty7 never sets, protecting a handler tty7
has already written.

So the exposure is one upstream default away: a release that moved `osc52` to
`CopyPaste` would hand any program able to write to a pane whatever is on the
clipboard, with no prompt and nothing in the sequence identifying who asked.
The program can be on the far end of an SSH connection, and a clipboard
routinely holds a password, a token or a private key. Terminals that offer the
read at all put it behind a prompt or an explicit opt-in.

`osc52: Osc52::OnlyCopy` is now stated where the rest of the terminal config
is, so writes still work and reads stay refused by tty7's own decision. No
behaviour change today. Same shape as `conpty_resize` two lines up, and it gets
the same kind of test, for the reason that one documents: a field whose default
already matches is a field whose line can vanish unnoticed.

The `ClipboardLoad` arm keeps a note saying what makes it unreachable and that
unlocking it needs a consent step first.

2937 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai adfdd398f6 fix(images): own the PNG decode ceiling instead of inheriting it, and test that path
A kitty graphics escape is untrusted input — any program running in a pane can
print one — and every bound on that path is deliberate and spelled out, except
this one. `to_rgba8` clamps an inflate to `min(declared, MAX_IMAGE_BYTES)` and
says why the declared size cannot be trusted on its own; the PNG branch called
`image::load_from_memory`, which leaves the ceiling to `Limits::default()`.

That default is 512 MiB — a real bound, eight times `MAX_IMAGE_BYTES`, and
somebody else's number. A patch release that relaxed it would silently widen
what a hostile escape sequence can allocate, and this runs in the GUI process,
so an OOM here takes every pane down rather than the pane that asked for the
image. The limit is now stated locally at the value the default has today, so
behaviour is unchanged and the invariant stops depending on a dependency's
opinion.

Why the number is not simply `MAX_IMAGE_BYTES`: that bound comes from what a
wire frame carries, and a PNG crosses the frame still compressed, so its
decoded size is the one thing on this path not already settled. Lowering it
would bound this properly but decides how large an image tty7 means to display,
which is a product call and not a cleanup — noted in the comment.

The PNG branch had no test at all, so the change is covered both ways: a real
2x1 PNG decodes at its own dimensions rather than the escape's `s=`/`v=`, and
a 1x1 PNG whose IHDR is rewritten to 0xffff x 0xffff (~17 GB, CRC repaired so
the decoder parses it) is refused. Checked out of band that the refusal really
is "Memory limit exceeded" and not a malformed-file rejection, so the test
cannot pass for the wrong reason.

2936 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai 23bbe7d400 diag(tree-sync): name the pane a refused tree operation strands
`TabCreate`, `PaneSplit` and `PaneReplace` each carry a pane the window has
already spawned — the shell is running on the machine before the tree is told
about it. When the tree refuses one of those, nothing ever takes the pane and
the re-pull leaves the window without the tab it was for, so the shell goes on
running with nothing referencing it. The log said only that an operation was
refused, which is the one thing that does not point at the leak.

Reproducible against a running instance: with a window open on a workspace,
`tty7 tab new` and `tty7 tab close` back to back. The GUI restores the new tab,
finds its pane already hung up, spawns a replacement, and `PaneReplace` is
refused because the tab is gone. Four cycles in five leak a shell; leave half a
second between the two and none do. Killing the GUI and repeating the loop
leaks nothing — the CLI half is correct throughout.

Not swept here, and the comment says why: at the point of the refusal the
window still holds a view for the pane and only drops it once the re-pull
lands, so hanging it up here would kill a pane that is still on screen. The
sweep belongs after the pull settles and has to test the whole machine tree
rather than this workspace's mirror, since a pane belonging to another
workspace on the same host is not this window's to end. That change wants a
GUI it can be watched in; this one only makes the leak say its own name, and
`tty7 pane ls --all` already points at the recovery.

`seeded_pane` is pinned by a test, because the set of requests that name a pane
into existence is exactly the set a refusal can strand one from — a new one
added without it would leak silently.

2933 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai e530d5f778 docs(rustdoc): fix the links that pointed nowhere, and gate rustdoc in CI
Nothing had ever run `cargo doc`, so 16 warnings had collected. Four were
links to items that do not exist, and two of those were worse than a dead
link: `git_badge` and `info_chip` documented their sizes in terms of
`PANEL_TEXT` and `PANEL_TEXT_META`, px constants deleted when the interface
font scale landed. The module comment twenty lines up already says they went;
the prose downstream still derived pixel arithmetic from them, so a reader was
being told the pill is 20px tall against a 19px neighbour when both are now
rems that move with `ui_font_size`.

Rewritten against the ladder that exists (`META_MONO` beside `TEXT_MONO`), and
`info_chip`'s comment now records what its own numbers imply: its padding and
radius are pixels wrapped around rem-sized text, so the two stop agreeing once
the interface scale leaves 100% — the same trap `PIP_SIZE` right below it is
written in rems to avoid. Left as a note rather than changed, because that is a
visual decision and this cannot see the result.

The other ten are `private_intra_doc_links`, and that lint does not apply here:
it exists so a *published* crate does not ship docs whose links dead-end, and
all four crates are `publish = false`. Allowed at the crate root with that
reason, because a public item explaining how it relates to a private one is the
useful half of these comments.

The `clippy` job becomes `lint` and runs rustdoc too, on the same warm cache.
Still non-required.

2932 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai 0d0969ac56 fix(test): stop closing_the_pool_drops_queued_work racing, and guard i18n placeholders
`closing_the_pool_drops_queued_work` never tested a queued job. `submit`
grows the pool whenever `jobs.len() > idle`, so blocking one worker and
submitting a second job spawns a second worker and runs it immediately —
the assertion only held when `close()` beat a brand-new thread to the state
lock. On a loaded machine it lost that race about one run in six.

A job only stays queued when the pool is saturated, so the test now fills it
to MAX_WORKERS first, asserts the job is on the queue before closing, and
waits for the workers to drain afterwards. 14 consecutive `-p tty7-core --lib`
runs green, against 2 failures in 16 before.

Placeholders in a translation were checked by nobody: `apply_template`
substitutes by name and leaves anything it was not given alone, so a `{name}`
dropped from the zh string reaches the user as a sentence that has simply
stopped naming the host, and an invented one draws braces on screen. Neither
is a missing or empty translation, which is all the parity test could see.
Both are now checked, for the plural and select branches too. Nothing was
wrong today.

The `KEPT_IN_ENGLISH` staleness check printed rather than asserted, and cargo
swallows a passing test's stdout, so the list could only ever grow.

The Linux build dependencies existed in four copies across three workflows;
release and nightly had the AppImage packaging tools (libfuse2, file,
imagemagick) mixed into the same list, which reads as if they were needed to
build. One script now, with the packaging extras installed separately, and the
ci.yml comment points at the docs page that actually documents the list rather
than at the README, which never did.

2931 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai c957231142 chore(lint): put clippy on the CI gate, and clear the ~200 findings behind it
CI checked `cargo fmt --check` and the build, so nothing ever read the
content of the code — ~200 clippy findings had accumulated, a third of them
in `#[cfg(test)]` modules.

Two of them were real:

- `resolved_background_appearance` took `backdrop`, and only the
  `#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy
  asks for and compiles cleanly on macOS; on Windows it is an undefined
  name. Kept the parameter and discharged it in the non-Windows arm the way
  `package_for_current_install` already does.
- `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were
  carrying translations in three languages for strings nothing reads.

The rest is mechanical: let-chains for collapsible `if let`s, struct-update
syntax for `Default::default()` reassignment, `sort_by_key` where a manual
reversed comparator was doing the same job the same file already did with
`Reverse` two functions later.

Where clippy was wrong, the reason is now in the tree rather than rediscovered:
per-platform `#[cfg]` blocks each keep their `return` (dropping it only
compiles on whichever target's block lands last), the loopback parser keeps
three parallel arms instead of folding one into a `?`, and the four wide enums
are all built on the stack and consumed immediately, so boxing them would add
an allocation rather than save one.

`L10nKey` cannot be clean under `dead_code` on any single platform, so the
allow there records how to audit it instead — which is how the eight dead keys
were found.

`english()` gained the cross-platform test its doc comment already claimed;
only the Windows hint had been pinned.

The clippy job is non-required until it has green history, for the same
branch-protection reason `host-boundary` documents.

2930 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 17:05:34 +08:00
l0ng-aiandl0ng-ai 3ef644d267 fix(scm): keep the sync tile on the branch row at any panel width (#650)
* fix(scm): keep the sync tile on the branch row at any panel width

The branch row's flex constraints were set on the Button, but
`dropdown_menu_with_anchor` hands that Button to a `Popover`, which wraps
it in a plain div and never applies the trigger style it was given
(`trigger_style` is stored and never read). The constraints landed inside
a box that still measured its own content, so at the panel's 216px floor
a 24-character branch name overflowed the row and pushed the sync tile
out of the panel entirely, with no way to reach it.

Carry `flex_1` on a wrapper instead, and truncate the name against the
width it is actually given rather than against a character budget that
was guessing at that width. The `elide_middle` ceiling is gone: stacked
on top of a real truncation it produced two ellipses in a row
(`fix/new-tab-……`) and threw away the tail it existed to keep.

Notes and chips move into one `overflow_hidden` group that is allowed to
shrink, so the order of who gives way is explicit: branch name first,
badges second, the tile never.

Also stop offering "Publish Branch" from a HEAD that cannot publish. A
detached or unborn HEAD has no upstream by definition, so the token fired
there unconditionally — the widest thing on the row, naming the one
operation the tile beside it already refuses (#545), and on its own
enough to push that tile off a 216px panel.

* fix(scm): drop the branch row's note box when it holds nothing

The row lays the notes and chips out in one shrinkable box so the sync
tile keeps its place. An empty box is still a flex item, so the row's
6px gap was spent on either side of nothing: on the quiet branch that
is most of what anyone looks at, the caret sat 12px off the tile
instead of 6px. Build the notes first and only add the box when there
is something in it.

Also drop two comment citations of #549, which is about palette
commands that no-op silently and has nothing to do with this row.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 15:18:12 +08:00
l0ng-aiandl0ng-ai 05de7ae33a fix(new-tab): keep the SSH menu inside a menu's shape (#649)
* fix(new-tab): keep the SSH menu inside a menu's shape

The saved-host rows carried names and endpoints long enough to drag the panel
out to the 500px ceiling PopupMenu falls back to, and the row that meant to
elide was clipped mid glyph instead. The menu now stops at 360px, and a row
that runs out of room cuts the endpoint first — the name is what the reader is
picking by, so it keeps whatever is left rather than being squeezed to "..".

The height ceiling moves up to fit the shape everyone actually sees — nine
shells, both headings, six hosts and the two closing rows — so the default menu
arrives whole instead of scrolled with "Local" cut off above, and is capped
again against the window so a short one never gets a menu taller than itself.

The rule above the split hint goes: a separator divides two lists of things to
pick, and the hint is a footnote about the list it follows.

Bumps gpui-component, where a scrollable PopupMenu painted a scrollbar whether
or not it overflowed, custom rows could not elide, and labels had no padding of
their own.

* fix(new-tab): measure the menu ceiling off the viewport, and elide nameless hosts

`window_bounds()` answers how a window should be reopened after it is
closed, so a fullscreen macOS window reports the bounds it would restore
to rather than the screen it currently fills. A terminal spends much of
its life fullscreen, where that reading capped the menu at 80% of a
window nobody is looking at — putting back the scrollbar and the
cut-off `Local` this branch is here to remove. `viewport_size()` is what
every other window-relative size in the app already measures against.

A host saved on its address alone is *named* `user@host:port` and carries
no note, so it took the plain-item path — bare text with nothing to elide
against, on the longest string in the menu and the row least able to cut
it. Every host row is a custom element now, and `menu_row` drops its
right half when the note is empty rather than holding the gap open with
a zero-width child.

Also drops 17 unrelated dependency downgrades that rode along with the
`gpui-component` bump. The lockfile moves only the three `source` lines
it meant to; `cargo check --locked` accepts it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 14:24:22 +08:00
l0ng-ai 6031570798 feat(new-tab): reach saved SSH hosts from the New Tab button (#647)
Adds a saved-SSH-hosts section to the New Tab menu on both the tab strip and the sidebar, ordered by frecency, with a row that opens the full host palette. Holding the modifier opens the host in a split instead of a tab.

The menu scrolls once the rows outgrow the popup, long host names and their endpoints truncate rather than overflow, a host with no name of its own draws its endpoint once instead of twice, and the rows are built when the menu opens rather than on every painted frame.
2026-08-15 11:28:42 +08:00
l0ng-ai cf6df5b469 fix(ssh): cover the whole window with the password prompt's scrim, and name the machine in a failed reconnect (#645)
Hoists the SSH password prompt's overlay from the body area to the window root so its scrim covers the title bar, tab strip and side panels, and aligns its top offset with the switcher card.

Replaces the raw target string with the resolved machine label on the reconnect banner and on the connecting pane, so a profile-backed machine no longer shows a bare config UUID in "Connecting to …" or "Could not reach …".
2026-08-15 11:11:26 +08:00
ARNOandl0ng-ai 2e6103cf19 Retire to the tray on window close; cold start no longer stalls on stale daemon files (#639)
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files

Two problems shared a root: the daemon outlived every window, and
nothing could stop it gracefully.
Window lifecycle:
- Closing the last window retires the app to the tray instead of
  quitting (QuitMode::Explicit), so the daemon stays reachable. The
  tray restores the most recent workspace, and Quit — after the
  confirmation that protects running shells — stops the daemon. Every
  explicit exit path (tray, palette, keybinding) now stops the server;
  no exit leaves an orphaned daemon behind a dead icon.
- A pathless launch (double-click) hands off to the registered GUI via
  GuiOpen(None) and exits, instead of starting a second process with a
  second tray icon.
- The tray subsystem initializes once per process; reopening a window
  no longer creates a duplicate icon.
Cold-start robustness:
- Liveness connects are bounded to 500 ms, the version handshake times
  out in 1 s, and an unresponsive daemon is reaped by its recorded pid
  instead of polled for a 6 s graceful stop.
- A dead recorded pid skips the TCP probes entirely — the GUI's
  ensure_running, the new daemon's endpoint check, and the
  control-listener occupancy check (which could also misread a reused
  port as a live control server and refuse to boot). Stale cleanup now
  also removes the leftover control.port.

* fix(daemon,gui): skip the GuiOpen handoff probe when the recorded daemon is dead

* fix(lifecycle): keep the stale-endpoint cleanup, and do not retire into a tray that is not there

Three gaps in the tray-persist and cold-start work.

`ensure_running` moved the refused-connect branch under the new liveness
check, so a connect that fails while `recorded_daemon_is_dead` says "not
dead" now skips the reap and the stale-endpoint removal entirely. The
pidfile answers "not dead" to two cases it has no evidence about: it is
missing (the daemon died between `transport::bind`, which writes
daemon.port, and `pidfile::write_current`), or it records a pid the OS has
since reused. Both then leave daemon.port on disk and the spawn poll pays
the OS's refusal delay on it — the cost this path was rewritten to avoid.
Restore the branch, and split the rule into `recorded_daemon_is_dead_with`
so a test can state that a missing pidfile is not evidence of death,
without an env var every parallel test would inherit.

The tray's windowless Quit stopped the server without a prompt, reasoning
that the confirmation is about the panes behind a window. It is not: it
says "anything still running in your shells is terminated", and retiring to
the tray is precisely what leaves those shells running with no window. Bring
the window back and deliver the action to it, so the confirmation appears;
only when no window can be opened does the bare stop remain, with a warning.

`show_tray_icon` is a request, not an outcome. `Backend::create` can fail
for a whole run — a Linux session with no StatusNotifier host is the
ordinary case — and after MAX_ATTEMPTS the loop gives up and logs. Retiring
on the config alone then leaves a process with no window and no icon: not
reachable, and still holding the daemon. Gate the retirement on an icon
actually being up.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 23:04:15 +08:00
l0ng-ai 5c284799aa fix(tabs): stop a command that is over in a blink from flashing across the tab
The tab title follows the terminal's OSC title, and nearly every prompt
framework sets that to the command it is about to run and puts the old
title back at the next prompt. For anything that finishes in a blink both
edges arrive within a few frames, so the label showed the command and
snapped straight back — a flicker that reads as a rendering glitch rather
than as information.

Hold a new title for 400ms before the tab adopts it. A title that reverts
inside the wait matches what the tab already shows and drops the pending
one, so a short command never reaches the label at all; one still running
when the wait elapses names the tab as before, 400ms later.

A second title arriving mid-wait rides the wait already in flight instead
of restarting it. Restarting is what would let a program that rewrites
its own title faster than the wait — a download reporting progress —
put the tab's next update off for as long as it ran.

Child exit clears the pending title and writes its own immediately: a
title still waiting its turn would otherwise land on top of "(process
exited)" a moment later.
2026-08-14 22:30:58 +08:00
l0ng-ai 3d6528737a fix(settings): give the page back its scroll range, and hold the bar off the window corner
The centring added in #631 turned the settings content box into a flex
column, and that cost the page most of its scroll range: the box is an
item of the scroll pane, which is itself a flex column, so its height
came out of a negotiation with the pane rather than from the rows it
stacks. `content_size` is just that box's laid-out bounds, so the range
ended a screen short of the last row — dragging to the bottom still left
content cut off. `flex_shrink_0` does not help; the height is agreed,
not squeezed. Centre with `mx_auto` on the column instead and leave the
box a block, which reports the full height it stacks.

While there, hold the content scrollbar 12px clear of the top and
bottom. Every other list this bar serves sits in a bordered panel where
running the full height is right; this pane is the window, and a bar
drawn to the last pixel lands on the rounded corner. New
`with_inset_vertical_scrollbar` takes the inset, and the existing
`with_vertical_scrollbar` keeps its behaviour for the other twelve
call sites.
2026-08-14 21:47:08 +08:00
l0ng-aiandl0ng-ai f08d8c2764 fix(remote): stop the server on machines that have no /proc, and show the install on the strip (#627)
* fix(remote): stop the server on machines that have no /proc, and show the install on the strip

Restarting the remote server timed out after ten seconds on every Mac and
BSD, with the old daemon still running and the new binary already sitting
next to it, unlaunched.

Both the probe that finds the running `tty7-server-*` and the command that
terminates it walked `/proc/[0-9]*` and read each `exe` symlink. There is no
`/proc` there. Two things then went wrong at once. zsh is the login shell on
macOS, and it aborts the whole command line when a glob matches nothing, so
even the trailing `true` never ran; and `cycle_daemon` discards the result of
the terminate, so a command that killed nothing was indistinguishable from
one that worked. `daemon_is_serving` then answered yes until the deadline.

Guard the glob behind `[ -d /proc ]` — unreached, it is never expanded, so
zsh has nothing to abort on — and fall back to `ps`, whose `comm` is the full
path on the BSDs. It cannot be the only branch: Linux truncates `comm` to 15
characters, one short of `tty7-server-c7p5`, which is why `/proc` stays the
first choice where it exists. `check_running_build` reads the same probe and
was equally blind on those machines; it can see now.

Separately, the install progress bar only ever existed inside the switcher.
Pressing Update Server from a parked workspace with no switcher open froze
the window for the length of the download and then produced a modal, with
nothing in between. The strip draws it too now — caption and bar from the
same source the switcher uses, and no button while an install is in flight,
since pressing it again would start a second one on top of the first.

* fix(remote): say why a stop failed, and stop a leaked install from eating the strip's button

Three things the no-/proc fix left standing.

`cycle_daemon` still discarded the terminate's result, which is the other half
of why a Mac cost a bug report: the command ends in `true`, so anything short
of success means the far end never reached the kill at all, and that is exactly
what a zsh abort looks like. It is now logged, and named in the timeout error —
"the running remote daemon did not stop within 10s" on its own blames a daemon
for ignoring a request nobody managed to send it.

The strip hides its Update Server button whenever an install is in flight,
which is right, but it reads the progress registry with no link state to temper
it — unlike the switcher. `finish_connect` bows out before clearing that entry
whenever `connect` has moved on in the meantime, and a switcher disconnect or a
move to another workspace both do that mid-install. The leftover froze a
progress bar on every window pointed at the machine and took away the one
button that could have fixed it. Cleared where the attempt actually ends
instead, however it ended.

The switcher kept its own copy of the progress bar after the caption was
shared; it draws the shared one now.

Tests: the probe runs for real in every shell on the machine rather than only
parsing under `sh -n` — the glob that started this was valid syntax and only
fell over when zsh ran it, which no `-n` can see. `ps` is checked on its own
where the fallback would actually be taken, since that arm eats its own stderr
and a rejected flag would otherwise cost nothing visible. And a stop that fails
is asserted to reach the error.

`with_shutdown_timeout` exists so that last test does not sit out ten seconds.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 19:15:01 +08:00
l0ng-aiandl0ng-ai 3bc8a764f7 fix(theme): stop the code editor painting its gutter and current line in the stock syntax theme's colours (#636)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:58:33 +08:00
l0ng-aiandl0ng-ai 34957f8659 docs(shell): name custom arguments as a reason integration never engaged (#634)
A pane that never armed shell integration blamed a PTY wrapper or an unsupported shell setup. Since #629 a zsh or fish the user gave arguments to is deliberately left alone, so the notice now names that first — it is the one cause the user can undo. All three locales.

The release notes gained the matching entry: the change turns integration off for an existing config that sets `shell` or a `custom_shells` entry with `args`, which is worth stating outright.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:57:24 +08:00
webdev 7fded5afd4 fix(tree-sync): record the panes a window seeded in its own mirror (#628)
A window's mirror of its machine held no `PaneRecord` for a pane the window itself created. Records ride inside layout deltas and a client is left out of the deltas its own ops raise, so the record the daemon mints when it registers a seed reached every window but the one showing the pane; `PaneFacts` closed the gap only when a fact changed, and a pane spawned into its directory and left at its prompt never changes one. The workspace answered no subject path, an unnamed one read "Untitled", and `record_geometry` stamped a null subject over the path views.json remembered.

The window knows what it seeded, so it now puts those records into its own mirror through the same `PaneSeed::into_record` the daemon mints with — opened up rather than duplicated, so ssh-secret stripping stays shared — with the window's own Ready terminals standing in for the daemon's liveness probe. The write is insert-only: a record the mirror already holds came from the machine and outranks what a seed knows.

Also closes the race that reopened the same symptom by another route: a `MachineGet` already in flight installed its tree whole and took the client's not-yet-acknowledged writes with it, and nothing re-inserted them until the next non-empty op. All four optimistic writers now go through one path that keeps each write for the life of a pull in flight and replays it over the tree that lands. The tree stays authoritative for everything it speaks about — only the ops it was built too early to know are put back on top, and the journal is drained once it has landed, so nothing a later tree dropped is resurrected. This covers #604's pushed tabs and workspace ops, not only the seeded records.

Fixes #612.
2026-08-14 16:17:07 +08:00
webdev 84a424006e fix(resize): defer the reflow to the daemon's Size echo on remote routes too (#632)
Since #415 the daemon echoes a `Size` frame at the stream position where the pty changes geometry and the client defers its grid reflow to that marker — but only on local routes, so a remote pane resized mid-flood still parsed queued old-width bytes into the new-width grid, which network transport makes worse.

Rather than probing `Version` per pane (a whole routed connection, and for ssh/WSL a whole bridge process, on every spawn and attach), the server advertises the pane protocol's features on its control hello. The answer is cached on the link and read off the host when a pane's route is built, and the route carries it to the terminal at spawn, attach and relink. This is additive within `CONTROL_VERSION` 7: no new field, just extra names in the existing `ControlHelloOk.features`, so an older client cannot choke and an older server that names no echo makes the client reflow at request time as before. A route built while the link is down answers false.

Known limitation, inherited from #415's design and not introduced here: there is no timeout if a promised echo never arrives — once deferred, a later identical resize neither re-sends nor reflows, so a wrongly-set bit would freeze the grid at the old geometry. Every traced path makes the control hello and the pane daemon the same build, normally the same process.

Closes #416.
2026-08-14 16:02:04 +08:00
webdev 422808191d feat(sidebar): group a tab by its folder when its cwd is not a repo (#631)
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.

The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.

Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.

Closes #620.
2026-08-14 15:57:36 +08:00
l0ng-aiandl0ng-ai 72db26d15a feat(prompt): let the shell's own line editor own the prompt (#633)
Closes #624

tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.

The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.

The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.

`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.

Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.

Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 14:46:22 +08:00
l0ng-aiandl0ng-ai b3698f4b59 fix(worktree): lift the new-worktree prompt to a window-level modal (#626)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 09:35:22 +08:00
l0ng-ai 1424da0891 fix: nine UX fixes across the diff overlay, layout, settings and pane spawn (#623)
Found by driving a dev instance and measuring what an idle window costs.

Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.

The rest:

- The tab sidebar and the right panel each capped themselves at half the
  window and knew nothing about the other, so together they could take
  all of it — 260 points of terminal on a 720-point window. Both now cap
  at whichever binds harder: half the window, or what is left after the
  terminal's floor and the other panel's floor. The same cap bounds the
  drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
  `git diff --numstat HEAD`; an unstaged or staged patch answers a
  smaller question, so opening an untracked file from the Source Control
  panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
  focused could not be left — the breadcrumb looks the path up in
  `files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
  loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
  `/private/tmp/x` in every tab opened from the first. `PWD` carries it,
  and POSIX has the shell discard a `PWD` that names the wrong
  directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
  generated from the binding table rather than the static index — so
  searching for a feature finds its shortcut, and the page filters to
  the matches. Closes #444.
- The settings reading column is centred rather than pinned to the nav:
  on a window as wide as the display it was made for, 640 points of
  settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
  carry — it opens a form asking for a name and a host.

Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
2026-08-14 08:42:29 +08:00
l0ng-aiandl0ng-ai 0e35611284 fix(switcher): scrub a deleted remote workspace from the listing snapshots (#622)
Deleting a remote workspace removed its store entry but left the
machine-listing snapshot every window keeps for the switcher untouched.
That snapshot is merged into the panel every frame, deduped against the
store — so with the store entry gone nothing held the row back, and the
workspace the user just deleted popped straight back into the switcher
as an adoptable machine row until the next reconnect replaced the
snapshot.

delete_workspace now captures the workspace's RemoteRef before removing
the store entry and drops that machine workspace's row from every open
window's snapshot. forget_workspace deliberately does not: forgetting
keeps the machine's session, and re-discovering it from the listing is
that flow's whole point (#485).

One test, confirmed to fail without the scrub. It drives the real
switcher_groups, so it covers the frame-time merge that resurrected the
row, not just the snapshot bookkeeping.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 08:34:13 +08:00
l0ng-ai 84f9d54ad6 fix(remote): finish the create a server update interrupted, and retire the note it answers
Creating a workspace on a machine whose server is the other side of a
dialect bump took two creates and two update clicks in different places.
Two holes in one flow:

The create parked on the connect died with the refusal —
finish_connect's Err arm dropped pending_create — so the update the
refusal band offered ran to completion and then nothing happened: no
workspace, and nothing left for any reconnect to finish. A create
refused for the dialect now moves aside to parked_create, the
replacement's success connects at the machine again, and whichever
connect finally lands spends it, name and all. Dismissing the refusal
or disconnecting the machine still calls the create off; every other
failure does too.

The mismatch note recorded during that failed attempt outlived the very
replacement that answered it: the queue is only drained after a
successful connect, so the next one raised "update this server?" about
a server that was already updated — and confirming killed the fresh
daemon all over again, sessions and all. reconnect_after_restart now
retires the origin's notes the moment a restart or replacement lands.

Three tests, each confirmed to fail without the change it guards. The
end-to-end one drives finish_connect against a real control server over
a socketpair, so the parked create is spent by the same code path a
live reconnect uses.
2026-08-13 22:20:43 +08:00
l0ng-ai e781bfddc6 fix(workspace): let a new workspace keep the name it was given, and cover the abort #618 fixed (#619)
A name typed into the create form was sent as `WorkspaceRename` the moment
the window switched — before the workspace existed on the machine to be
renamed. The machine answered `NotFound`, `unsendable` logged it at debug
and dropped it, and the create that ran afterwards named the workspace
whatever codename it rolled. Nothing replays it: the next sync diffs tabs,
not names. It flashed on screen first, because `fire_workspace_op` notes the
op in the mirror before sending it, so the name appeared and then reverted.

The name now travels with the create instead of chasing it. It is parked on
the window's sync state by `name_new_workspace` and spent by whichever
create runs — `pull_workspace`, which is the one `switch_workspace` actually
reaches, and `pull_or_create`, which races it (both create when the tree
they read did not hold the workspace yet, as the `Err` arm of
`pull_workspace` already described). Both read it inside their spawned task
rather than before it: a window orders its pull first and is named second,
so anything read earlier is still empty.

`settle_chosen_name` arbitrates against what the machine answers. A name it
read back was spent by the create. One it did not means the create never ran
— the workspace was already there — so it goes out as the rename it has
become. A window that chose no name still reads whatever the machine says,
which is what #604 fixed.

Also covers the abort #618 fixed a commit ago. That fix is right and is left
as it is; it landed without a test, and `tabs_on_screen` opening with
`if !cx.has_global::<WindowRegistry>()` is why the whole suite passed over
the read below it — no test installs a registry. The test here installs one,
which is what `WindowRegistry::register` is no longer private for, and fails
with an abort against the code as it stood before #618.

Six tests, each confirmed to fail without the change it guards.

Not verified end to end: there is no fake control client in the tree, so
what `WorkspaceCreate` carries over the wire is covered by reasoning and
unit tests only.
2026-08-13 19:56:32 +08:00
l0ng-aiandl0ng-ai 8296161b4a fix(remote): give a dialect refusal a way out instead of a retry loop (#617)
* fix(remote): give a dialect refusal a way out instead of a retry loop

A remote workspace whose server is the other side of a control-dialect
bump reconnected forever: the strip quoted the protocol layer's own
wording verbatim inside a localised sentence, offered Retry Now, and
counted attempts at 30s intervals. Retrying cannot work — neither build
changes between attempts — and the only Update Server button lived in
the switcher's error band, which a window that opens straight onto the
remote workspace never reaches.

Park the link on a refusal and put the working action on the strip.
Restart Server now routes to the replace flow when the far end speaks
another dialect, because restarting was the wrong action there twice
over: it killed any running tty7-server-* and then launched the path
named after *this* build's dialect, which on such a machine does not
exist. Installer::restart_daemon now probes that binary before killing
anything and refuses when there is nothing to start.

CONTROL_VERSION moves to 7 with no message change, so the refusal path
can be exercised against the v6 servers already deployed.

* fix(remote): recheck a parked link, and name a downgrade a downgrade

Review follow-ups on the dialect-refusal parking.

`is_dialect_refusal` was a substring sniff on the marker while every reader
of a `true` went on to parse the whole shape. Two predicates for one
question, and the weaker one decided whether to park a link that only a
person could free. It is the parse now.

A parked link never looked again. `RouteLost`, the state it was modelled on,
is re-tested every tick and comes back by itself; this one could not, so a
machine somebody else updated — or one that rebooted onto a build that does
speak to us — sat there claiming to be broken for the rest of the session.
It looks again every five minutes: a slow clock, deliberately two orders of
magnitude off the reconnect one, and the strip says nothing while it does.

`retry_now` cleared `last_error` for every caller, so pressing Retry Now on
an unreachable machine cost the user the reason why until the next attempt
finished. Only leaving a park clears it.

The one button read Update Server in both directions, including the one
where installing our server takes the far end back a version. That direction
reads Replace Server, and the confirmation it opens offers the same word the
button did rather than renaming the act between the click and the prompt.
The switcher's band gates that button on `hosts_our_server` as well now,
the way the workspace strip already did.

`a_dialect_refusal_parks_the_link_instead_of_counting_attempts` passed
without reaching what it named: an `Alias` resolves only if the machine
running the tests has that name in its ssh config, and the pump drops an
unresolvable route before it reaches any parking. It uses a target that
always resolves now, and both new pump tests were checked against a mutation
that removes the recheck.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 19:56:21 +08:00
ARNO 1d0b648f66 fix(tree-sync): stop workspace switches from rereading the app mid-update (#618)
fmt
2026-08-13 19:27:33 +08:00
l0ng-ai d81dbc5e11 fix(i18n): delete unreachable zh arms and 534 lines of stale exemptions
Four arms in zh.rs were dead. A merge appended a second copy of
CmdClearScrollback and of PanelMoreChangedFiles (singular plus its zero
and one plural branches) below the arms that already matched those keys,
so the later copy never ran. The compiler had been reporting all four as
unreachable patterns; the warnings were buried among 19 others. Keep the
version without backticks around `git diff`, which is what en.rs reads,
and move the surviving other branch back next to zero and one.

KEPT_IN_ENGLISH exempts keys whose translation is allowed to equal the
English string. It had grown to 567 entries, 12 of them duplicated
within the same array, while only 33 keys still read as English. The
rest were added when the ja-JP locale was split out and every ja value
was still a placeholder; the translations landed and the exemptions
never left, so the untranslated-string guard had stopped guarding for
over 500 keys. Rebuild the list from what the locales actually contain.

The comments in that list had also come apart from the keys they
explain, the flat block having been spliced through the middle of a
group: "A language is named in its own language" sat above a run of
SettingsSearch*Keywords rather than above the three language names.
Every remaining key now sits under the comment that gives its reason.

Verified the guard bites: reverting one formerly-exempt key to its
English string fails the test, which it did not do before.
2026-08-13 18:48:26 +08:00
b2d73ec68b feat(update): update an all-users Windows install through one UAC prompt (#562)
* fix(update): surface a failed install instead of silently re-prompting (#540)

The GUI quits as soon as tty7-updater is spawned, so an install that
failed inside the helper left a trace only in update.log — and because
launching the helper had already cleared the prompt state, the next
check offered the same version again, and again. The failure mode the
user saw was an app that nagged about an update it could not install.

The helper now writes update-outcome.json beside update.json on every
terminal path it can still reach, and the next GUI launch folds it into
the update state: a failure shows in Settings with the installer's own
reason until dismissed and stops the version from re-prompting on its
own; a success at the running version retires a failure an earlier
attempt recorded. A leftover result that exists but cannot be parsed is
reported rather than dropped — something ran, and "unreadable" is a
result too.

The same change moves the config directory off the environment and onto
the command line (--config-dir). An elevated child process does not
inherit the spawner's environment, so TTY7_CONFIG_DIR would have fallen
back to the administrator's config directory exactly in the
over-the-shoulder case — the groundwork this lays for #504. The updater
re-exports the variable for the helper children it spawns itself, so
the relaunched app keeps answering for the same config directory.

* feat(update): update an all-users Windows install through one UAC prompt (#504)

An Inno install under C:\Program Files could not be replaced in place:
the updater ran the release Setup as the signed-in user, which either
installed a second, per-user copy beside the real one or let Inno
re-launch itself elevated — a bare UAC prompt for an unsigned
executable in %TEMP%, seconds after the GUI had vanished. So the
layout was refused outright and told to download by hand.

It now updates itself, with the split the design in #504 settled on:
one UAC prompt covering two privileged stages, and one watcher that
is never elevated at all.

- The GUI probes the *installed* updater for the new verbs by running
  it ("capabilities"), so a side-loaded or downgraded binary answers
  for itself instead of being trusted by version number. An updater
  that predates the verbs exits with a usage error, and the install
  falls back to pointing at the release page exactly as before — the
  first release carrying this still updates the old way, and the one
  after it updates itself.
- The prompt dialog says the UAC prompt is coming before the app
  quits, and stops offering "Install on Next Launch": nobody is there
  to answer a prompt before the first window exists. The same guard
  keeps a staged plan from being armed for the next launch, and
  apply_pending_at_launch leaves an elevation-needing plan staged
  rather than raising a windowless prompt at boot.
- "Install now" spawns the watcher first (medium integrity, the
  signed-in user's token, so the relaunched app is never elevated),
  then ShellExecuteEx "runas" on the installed updater — the trust
  root a medium-integrity process cannot rewrite. Everything the
  elevated half needs crosses as command-line arguments, because an
  over-the-shoulder child inherits neither the environment nor the
  user's profile. The package's expected SHA-256 crosses the same
  way, from the checksums the GUI already holds in memory, so a
  payload and its checksums file cannot be rewritten together behind
  the IL boundary.
- The privileged first stage re-verifies the payload against that
  digest, pins its helper byte-for-byte to the installed updater,
  stages both in a fresh administrator-only %ProgramData% directory
  (an explicit SDDL DACL, swept of stale directories first), and only
  then runs the install stage — which runs Setup silently, writes the
  outcome file, and never touches the app binary itself. The watcher
  follows the chain through the status file and pid liveness
  (ERROR_ACCESS_DENIED from OpenProcess still means "alive" across
  accounts), then relaunches the app de-elevated and probes that it
  actually came up.
- Declining the UAC prompt is not an error: the watcher is reaped,
  nothing ran elevated, and the staged package simply waits in
  Settings.

Persisted plans from before this protocol serde-default a plan
version that is_usable rejects, so a stale plan is discarded instead
of failing against a helper that would not understand its arguments.
The installer script's explorer-menu registration gains skipifsilent:
a silent run *is* this update path, and launching the app there would
write the menu into the administrator's hive under over-the-shoulder
elevation.

One note on the test suite: ui::remote_connect's
a_routed_auth_prompt_carries_the_machine_that_raised_it fails under
parallel test execution on this machine both with and without this
change — a pre-existing flake, unrelated.

* fix(update): run the UAC request off the UI thread

Real-machine verification of the elevated chain caught this on the
first click: ShellExecuteExW pumps the calling thread's message loop
while the shell raises the consent prompt (its change notifications
re-enter the window), and from the UI thread that re-enters gpui with
its App already borrowed — the process aborts on a RefCell
double-borrow before anything ever elevates. The launch — watcher
spawn included, so the pairing stays atomic — now runs on the
background executor, and only the bookkeeping (quit / decline /
failure) comes back to the UI thread.

* fix(update): throttle a failed version instead of retiring it (#540)

Per the review on #540: a failed install must not keep the version
retired via last_prompted — record last_prompted plus a fresh
remind_after deadline (the same three days "Later" uses), so the
version asks again once the reminder expires. should_prompt already
treats "last_prompted matches, reminder expired" as prompt-again, so
no logic change is needed there, and the pinned
a_failure_lets_the_version_prompt_again test still holds.

Also write update-outcome.json *before* relaunching the previous app
on the macOS/Windows/portable non-elevated paths: the GUI that comes
up next is exactly the process that absorbs the outcome, and it used
to be relaunched before the failure existed on disk. The elevated
chain is unchanged — its watcher already waited for the file.

* fix(update): let only the elevated updater's own image name the trust root

Three holes on the privileged side of the #504 chain, all of the same
shape: a value that decides what runs elevated was taken from the
medium-integrity caller.

- `elevated-stage` pinned the staged helper against
  `<install-dir>\tty7-updater.exe`, where `<install-dir>` is a
  command-line argument. Both halves of that comparison were the
  caller's to choose: name a directory holding two copies of any
  binary and the pin passes, then stage 2 runs it elevated. The stage
  now derives the installation from its own image — UAC pointed the
  prompt at `{app}\tty7-updater.exe`, so `current_exe` is the one path
  nothing below the boundary could have written — and passes that on
  to stage 2. A caller that named a different directory only gets a
  line in the log.

- The staging directory's DACL let no standard user in, but its parent
  did: `%ProgramData%` grants Users the right to create directories,
  and the creator owns what it creates. A pre-created
  `%ProgramData%\tty7` gave its owner delete-child over the
  administrator-only staging inside it — enough to rename the verified
  staging aside and drop an identical name of their own into the gap
  between the digest check and the execute. The root is now created
  with the same protected descriptor, taking down whatever holds the
  name first; `CreateDirectoryW` applies a descriptor only when it is
  the one creating the directory, so succeeding is the proof. The
  per-run sweep goes with it — the root's removal takes the leftovers.

- The GUI aimed the prompt at the updater the *plan* named, and
  `update.json` sits in the user's config directory. It now aims at
  the installation this process runs from, so the binary the prompt
  names is the binary that starts.

Also quote the elevated command line the way `CommandLineToArgvW`
reads it back: a backslash escapes only in front of a quote, so a
config directory ending in one used to escape its own closing quote
and swallow every argument after it, `--result-file` — the file the
watcher waits on — included.

* test(update): pin the elevated stage's trust root to its own image

A regression test for the shape of the hole rather than the hole: if
`installed_root` ever goes back to reading an argument, the pin the
elevated stage runs before executing the staged helper stops meaning
anything, and nothing else in the suite would notice.

* fix(update): bring tty7 back when the elevated chain never reports

The watcher's two timeouts returned without relaunching. Every other
way out of the chain ends with the app back on screen, but a stage 1
that died before writing its status or its outcome — killed, crashed,
an AppInfo service that never delivered it — left the user with the
GUI already quit, nothing to replace it, and nothing said. Same for an
install still running an hour later.

Both paths now end the way the others do: an outcome the watcher wrote
itself, then the relaunch. The synthesized outcome is written whether
or not the relaunch succeeds, which also closes the same gap on the
pre-existing "the elevated updater exited without recording a result"
path — the next launch can name what happened instead of silently
offering the version again.

What kept those paths from relaunching was the risk of a second window
beside a GUI that is still up: a declined prompt leaves this process
running, and the kill that reaps its watcher can lose. The watcher now
takes the GUI's pid and opens a handle to it at startup — while the
GUI is provably alive, since it is sitting in ShellExecuteExW waiting
on the prompt — so the number cannot be recycled out from under it.
Before relaunching, a GUI that is still alive is waited out for 30
seconds: one that is quitting (a chain that failed fast can beat it
out the door) is gone well inside that and gets its relaunch, one that
is staying is recognized as staying and gets neither a relaunch nor a
failure record it did not earn. A live process always answers to its
own pid, so the check cannot be wrong in the direction that
double-launches.

Also give the Japanese elevation notice its closing 。

* fix(update): poll the parent out across the elevation account boundary

Under an over-the-shoulder elevation the install stage runs as the
administrator, and OpenProcess on the signed-in user's GUI answers
ERROR_ACCESS_DENIED - the same boundary pid_alive already documents from
the watcher's side. wait_for_exit treated that as a fatal error, so the
chain recovered and reported a failure before Setup ever ran. The wait
now degrades to polling the pid until it stops answering, bounded so a
recycled pid cannot hold the install hostage forever.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <l0ng-ai@users.noreply.github.com>
2026-08-13 18:13:20 +08:00
l0ng-ai 2b6eab5f4a fix(sidebar): give the workspace head a width of its own
The workspace tile is a chain of percentages — `w_full` on the button,
on the div wrapping it, and on the row inside it — and a percentage is
only a width while every box above it has one. The row holding the tile
declared none: it borrowed the rail's by cross-axis stretch. On a pass
that sizes the column from its content the chain has nothing to resolve
against, the button falls back to its `px_1` padding, and the tile ends
up hugging the workspace name in a rail several times its width.

The row now declares the width it was borrowing, which anchors the chain
to the rail itself — that is a fixed `w(px(width))`, so every link below
it resolves.
2026-08-13 18:11:20 +08:00