Commit Graph
758 Commits
Author SHA1 Message Date
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
Hongwei Qinandl0ng-ai a2d53a9597 fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594)

The group-level Discard prompt asked to "discard every change in this
repository", but discard_all_ops has only ever swept unstaged edits and
untracked files — staged changes survive, as the function's own comment
notes. Users confirmed under one belief and the code kept another.

Narrow the prompt to the operation's real footprint, in all three
languages.

* fix(scm): keep the amend toggle when its confirmation is cancelled (#595)

scm_commit cleared scm.amend when Commit was pressed, before the
"rewrite the last commit?" prompt. Answering Cancel returned to a panel
whose amend mode had silently been dropped, so the next Commit created a
brand-new commit — exactly what the user had just declined to risk.

The toggle now clears where scm.committing arms, at dispatch in
run_git_op, extending the rule the armed flag already followed: a
cancelled confirmation leaves nothing behind.

* fix(cli): answer a wait timeout in the success path's JSON shape (#589)

The 124 branch returned {pane,status,timed_out} while a finished wait
returns {pane,status,matched,stale,activity,message,session_id} — so the
one branch a consumer writes error handling for was the one missing its
fields. The timeout now carries the full shape plus timed_out, and
reference.mdx documents the schema and the flag.

* fix(cli): report a failed wait on stderr, even under -q (#590)

wait's failures are structured exits (124, or 1 when the pane died
first), so they never passed through the anyhow path whose eprintln is
the only thing quiet mode cannot silence — contradicting the documented
"errors still go to stderr". Both exits now print their headline to
stderr, the discipline pane close already established.

* docs(cli): describe owner as the workspace that may attach (#591)

commands.md still claimed the CLI stamps a literal "tty7-cli" owner on
the panes it spawns — the behaviour the orphan-workspaces work removed,
because an owner names the workspace allowed to attach and a stranger's
stamp got the panes respawned. Every spawn path now writes the workspace
id, or nothing while the pane is still unfiled. Bring commands.md in
line with reference.mdx, and note the absent case in both.

* docs(cli): close five contract drifts between the tables and the code (#592)

- The key tables listed pgup/pgdn as aliases but not pgdown, which the
  parser has always taken; both references name it now.
- "Case-insensitive" was flat wrong for Alt: M-x keeps its case because
  Alt is a prefixed ESC, unlike Ctrl. Both references note the exception.
- procs' ports JSON has carried addr since the field exists; both schemas
  show it.
- TTY7_WS is tab ls's default too; both environment tables say so.
- split --ratio's clamp to [0.05, 0.95] was discoverable only in code;
  both split sections document it.

* fix(cli): doctor exits 1 when the server is unreachable (#592)

doctor is the verb people run when something is not working, so an
unreachable server is *the* finding — not a row to exit 0 over while
`tty7 doctor || alert` never fires. The table and JSON still go out
(the context rows are the other half of what doctor is for), and stderr
carries the headline under -q. MockBackend grows an `unreachable` flag
so the branch is testable; no Status/Routes round-trips happen once
hello has failed.

* fix(settings): refuse a Start-in path that names no directory (#601)

The custom path was stored unchecked, and the daemon's picker then
skipped it — not a directory — so every new pane silently started in
the fallback directory and the typo read as a tty7 bug. Settings now
marks the field red and refuses to save, the proxy row's pattern
(#551), with the red line and the commit gated on one shared predicate
so they can never disagree; a hand-edited config.json holding such a
path gets a log::warn! naming it at the moment the fallback engages.

* fix(terminal): rescan search highlights when the pane's width changes (#586)

A match point is an absolute (line, column) against the width it was
scanned at, so a column change reflows the text out from under every
highlight. Output rescans them (Wakeup → refresh), but a quiet local
pane has no output coming and the drift outlasted the resize
indefinitely. set_grid_size now rescans on a column change with the
output path's discipline — selection and scroll untouched — and takes
the Context it needs to do so; a rows-only change reflows nothing and
stays cheap.

* fix(terminal): keep the grid selection when the search bar opens and closes (#584)

The selection that seeds the query is the thing being searched for, yet
opening the bar ran recompute_matches' unconditional clear — right for
its other callers, where the user *changed* the query and the old
selection names nothing — and closing cleared it again, so select →
Ctrl+F → Esc lost the selection every time. The seeded selection is now
restored after the opening scan, and close_search no longer clears; a
query the user actually changed still retires the stale selection, the
discipline refresh_matches_after_output already stated.

* fix(tabs): a zoomed pane stays zoomed across a tab switch (#599)

Zoom was a window-level value that activate() cleared unconditionally,
so looking at another tab and coming back restored the split layout —
while a zoom is a tab's temporary view state, like its focused pane.
It now rides with the Tab: activate stashes the outgoing tab's zoom and
brings the incoming tab's back. The clears that genuinely reshape the
layout (drag, split, close) still stand, and a stashed zoom whose pane
exited while the tab was away is validated away rather than restored.

* fix(tabs): track an open rename box by tree id, not index (#598)

The rename box held only an index, which drifts the moment any other
tab closes or the strip reorders — so close_tab_inner and
apply_tab_order threw the half-typed name away on any unrelated tab
event, and a reorder mid-rename still left a window where the commit
landed on whichever tab had taken the index over. The box now names its
tab by tree id end to end (start, render match, commit): only closing
the renaming tab itself ends the rename, and the name lands on the tab
the box was opened on wherever it has since moved.

* fix(i18n): move seven hard-coded user-facing strings into the language tables (#602)

Seven spots rendered English no matter which UI language was set: the
shell-integration notice that explains why a wrapper was blocked or never
engaged, the titles a pane wears once its process exits or the server
loses it, the loopback forward's failure line, the tray tooltip that
lists running agents (whose separator also wanted a CJK enumeration
comma), the cursor-shape choices in settings, the command palette's
empty-result hint, and the updater's install hint. Each is a L10nKey now
with en/zh/ja entries, so the parity guard keeps them translated from
here on.

The palette's empty state was also wrong in content, not just language:
every menu suggested connecting over SSH when nothing matched, including
menus that have no hosts in them. The hint now only appears in the
quick-connect menu; everywhere else the palette suggests a different
search instead.

Verified on Linux: the title/palette/tray suites (48 tests) and the i18n
parity guard all pass.

* fix(terminal): show remote path completion is listing, and say when it fails (#585)

Tab-completing a path on a remote workspace had two silences. The whole
network round-trip painted nothing, so a slow link read as a broken Tab
key; and a listing that failed was unwrapped into an empty candidate
list, so "the directory is empty" and "the listing never happened" ended
in the same nothing.

A pill over the pane's bottom-right corner — the style the integration
notice already uses, factored out — now says the listing is running from
the moment it starts, and a failed listing sets a notice with its error
instead of the empty vector. The failure pill stays until the next
keystroke dismisses it, and the trailing notify after an empty listing
closes the menu brings the "listing…" pill down with it.

Verified on Linux: the new gpui test covers the idle/listing/failed
states, and the neighbouring completion tests still pass.

* fix(files): quote cd Here / Insert Path for the shell the pane runs (#593)

Both file-tree actions wrapped a path with spaces in POSIX single quotes
whatever the focused pane's shell was. In cmd.exe a single quote is an
ordinary character, so `cd 'C:\Users\me\My Documents'` split at the
first space and cmd complained about 'C:\Users\me\My' — while the same
action was fine in PowerShell and bash, which is why only cmd users ever
saw it.

shell_quote_for takes the pane's shell program (the pane already knows
it — the settings page lists it) and picks double quotes for cmd.exe,
single quotes for everything else; an unknown shell keeps the POSIX
form, and a path that needs no quoting stays bare either way. Windows
paths cannot contain a double quote, so the cmd form has nothing to
escape.

* fix(cli): pane close fails for a pane the registry does not hold (#588)

`tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane
that never existed. The workspace path cannot drift this way — PaneClose
answers — but an orphan has no workspace to route through, so close
hangs it up directly, and that kill is fire-and-forget: the daemon never
says whether it knew the pane, so Ok(()) only ever meant the bytes
reached the socket. A reaper script chasing the orphans `pane ls --all`
points at would read the ghost success as cleanup done.

The direct path now reads the running-pane registry once per batch and
refuses ids it does not hold: the miss lands in `failed` with exit 1,
next to the failures kill itself can report. A pane that exits between
the listing and the kill is gone either way, which is what closing it
wanted, so that race still reports closed.

* fix(session): a launch that leaves workspaces running says so (#597)

Quitting with several windows open and starting again restored only the
most recent one; every other open window was marked detached — panes
alive, nothing on screen, the only trace a "left N detached" log line.
The workspaces were reachable from the sidebar, but nothing said they
existed, so they were easy to forget entirely.

restore_one now returns how many windows it detached, and both launch
paths (normal startup and the CLI-driven open) push an in-app
notification into the restored window naming the count and where to
reopen them. The count rides the return value rather than firing the
notification inside the store, because the store has no window to notify
in — and a launch that detaches nothing, like the reattach-the-last-
closed case, stays silent.

* fix(switcher): list the local machine's orphan panes, with a way to close them (#596)

A pane whose workspace went away — an interrupted `tty7 run`, a forgotten
workspace that kept its shells — was invisible everywhere in the GUI: not
in the sidebar, not in the switcher, not in the tray. It kept its process
and its memory, and the only way to even learn it existed was the CLI's
`tty7 pane ls --all`, which a GUI-only user never runs.

The switcher's local machine group now carries a "Background panes" block
under its workspace rows: one line per live pane the daemon's registry
holds and no workspace does — id, owner, cwd — each with a Close button.
The listing is the same PaneClient::list the CLI's reaper reads, fetched
off the UI thread when the panel opens; closing kills and then re-lists,
so a pane that survived simply stays on the list instead of pretending
to be gone. The block steps out of the way while the search field holds
a query, which narrows the panel to workspaces.

Local on purpose: a remote machine's orphans belong to its own daemon,
and routing a listing per host is what the CLI reaper is already for.
The block joins no keyboard navigation — the panes are not workspaces
and the arrows have no business landing on them.

* fix(updater): keep Inno's progress window on screen during the install (#600)

The Windows installer ran /VERYSILENT, so from the app quitting for the
update to the watcher bringing the new build up — tens of seconds, longer
under an antivirus scan — the screen held nothing at all: no window, no
progress, no tray note. "Clicked update, the app vanished" reads as a
crash, and double-clicking the icon does nothing while the files are
being replaced.

The installer now runs /SILENT instead. Nothing about the flow becomes
interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS
are untouched — but Inno's own progress window stays on screen for the
gap, which is exactly the span the user had no word about.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 18:11:17 +08:00
l0ng-ai fd0a61b9d6 fix(tab-strip): ring the status dot in white on dark themes
The badge halo was drawn in the surface colour, which only reads as a
ring while the surface is light. On a dark theme it went near-black and
looked like a notch bitten out of the avatar instead of a badge sitting
on it. Detect a dark surface and give it the same white edge — and the
same white hole for the hollow Waiting dot — that light themes get.
2026-08-13 17:57:51 +08:00
l0ng-aiandl0ng-ai 3c0a700907 feat(switcher): flat workspace list, create form, connect-time workspace sync (#616)
* feat(switcher): flatten the workspace list, add a create form, sync remote listings on connect

The switcher's left column is now one flat most-recently-used list across
all machines, each row carrying its machine and link state; the per-machine
tree, headers, and the Other Machines band are gone. Machine trouble
(install progress, connect errors, parked routes) moves to contextual
banners under the list, and machine verbs move into each row's menu.

Cmd+Shift+N now opens a create form instead of silently swapping the
workspace: a name prefilled with the usual generated codename, and a host
combobox (searchable dropdown) defaulting to this computer. Creating on a
machine with no live link connects first and completes when the link is up.

Connecting to a machine also mirrors its workspace listing into the local
store, so its workspaces survive a restart without a connection. Mirrored
references are marked synced: launch restore skips them, and their clock
follows the machine only until this client opens them.

* fix(switcher): drop a parked create when its machine's connect is called off

Disconnect clears the in-flight connect, so finish_connect never runs and
the PendingCreate outlived the intent behind it: the next successful
connect to that machine would have silently created a workspace nobody
was waiting for anymore.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 17:35:58 +08:00
Hongwei Qinandl0ng-ai 8b42905fca fix(terminal): stop menu click-through and surface failed file opens (#541, #542) (#575)
* fix(terminal): stop terminal pop-up menus leaking clicks into the grid (#541)

The completion menu and the reverse-search menu (the floating panel and
the input-bar row alike) carry no click handlers of their own, and in
gpui an element without handlers, cursor or occlude inserts no hitbox —
the same rule the app pins with a test pair in app.rs. A press that
missed every row therefore fell straight through to the live grid: it
moved the cursor and cleared or started a selection there, and a
modified click even opened whatever link happened to sit under the
menu, so the menu read as broken while the damage landed elsewhere.

All three menu roots now occlude, the same remedy the terminal search
bar already uses, so a click on menu background is swallowed where it
lands. Making the individual candidates clickable instead is a separate
feature, not part of this fix.

* fix(terminal): toast a file link that fails to open instead of dying silently (#542)

The external half of open_file_link has no failure channel: a misspelled
link_file_command or a missing xdg-open only produces a log::warn, and
the click reads as a dead link — while the path was only ever underlined
because the pane's own host verified it exists, so "nothing happens" is
the worst possible answer. The URL half at least toasts a failed
loopback forward; the built-in editor arm reports downstream of
OpenFileRequested; the two spawn arms had nothing.

open_file_path and run_file_command now return io::Result, and
open_file_link turns an Err into the same kind of notification a failed
image paste raises, naming the path and the error. The file tree's
directory fallback — the one other caller, handing a path to the OS
association — gets the same toast instead of silence. A template whose
tokens all expand to nothing (a lone {line} on a link with no line
number — a blank template never gets this far, sanitize maps it to
None) reports as an InvalidInput config error rather than a silent
no-op. Spawn is still all that is reported: an opener that spawns fine
and then exits non-zero is nobody's to see, and a test pins both error
paths.

* test(terminal): pin the press a pop-up menu has to swallow (#541)

The menus occlude now, but nothing held them to it: a bare div over the
grid renders the same and only the mouse can tell the difference. This
presses on a history row and asks the grid whether it started selecting,
then takes the menu away and presses again — the second half is what
keeps the first from passing on a pane the mouse never reached.

* docs(changelog): say what a leaked press actually did (#541)

A press on a menu never moved the terminal cursor and the menus have no
buttons to miss; what it did was clear the selection, drag out a new one,
underline the text under the row on hover, and open the link beneath it
on Ctrl+click.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:54:28 +08:00
l0ng-aiandl0ng-ai 3cd90c6ca6 feat(ssh): name panes after their host, reach the host form from where you are, and test a connection (#566)
* feat(ssh): name panes after their host and reach the host form from where you are

Five gaps in the SSH flow reported in #438, and the two silent no-ops
around them.

Panes now carry a display name: a saved host's own name, or its address
when nobody named it — every host imported from ~/.ssh/config arrives
nameless, and each one opened a tab reading "tty7". The name survives a
title reset and a dropped link, and `strip_host_prefix` no longer cuts
`deploy@10.0.0.5:2222` down to "2222" on its way to the tab strip.

The host form is now reachable from the machine in front of you: the
switcher's machine menu edits the host it is showing, or offers to save
one for a machine reached by address or by ~/.ssh/config alias. A live
connection dialled by hand can be kept as a host from the palette, with
everything it was dialled with carried over — the one thing that cannot
come along is an ad-hoc jump hop, and that is said out loud rather than
saved broken.

The New Tab menu lists the saved hosts, most-used first, and the typed
address route that already understood -p, -J and config aliases. Both
were behind the workspace switcher, its host dialog and the settings
list.

Finally, the three proxy fields are exclusive — map_proxy picks the
first one filled and ignores the rest — so the ones that lose now say
which field won instead of leaving it to be discovered by connecting.

* feat(ssh): test a connection from the host form, and pick a host by name

Three things the host form and the New Tab menu were missing.

The form gets a Test button. It hands the spec to the daemon, which
dials it the way Connect would — proxy, jump host, host key, auth — and
drops the connection again, reporting how long it took. A test never
rides an existing connection: one would answer for the credentials that
connection was made with, so a password typed wrong would come back
green. Anything the handshake stops to ask a person is declined on the
spot and reported as what it asked for, since a form is nowhere to
answer a password prompt and waiting out the two-minute prompt timeout
would be a worse answer than "it got that far and wants your password".
The result clears the moment a field changes: vouching for a host that
was edited since is worse than saying nothing.

The Auth row becomes a dropdown. Six methods is more than a segmented
control can label without squeezing, and it is the row that stacks
first on a narrow page.

The New Tab menu scrolls — PopupMenu only does that past 20 items, and
every shell on the machine plus a handful of hosts already runs off a
short window — and past the five hosts it lists, Find a Host opens a
filter box over all of them.

* feat(new-tab): put a search box on the New Tab menu

The menu is as long as the machine is — nine shells here, and a
~/.ssh/config with two dozen hosts in it is ordinary — so it needed
filtering, not a scrollbar and a row leading somewhere else.

A PopupMenu cannot hold a text field: it claims the keyboard for its own
navigation, and there is no search input anywhere in it. So the New Tab
button now opens a popover holding the same searchable list the command
palette is built from, with the shells and the hosts under their own
headings and one box over both. Typing filters across both groups;
typing an address offers to connect to it, the way the palette does.

The standalone host picker this replaces is gone with it, and the row
that led there — one search reachable from the button beats two behind
a menu.

* revert(new-tab): put the New Tab menu back to shells only

The searchable popover was the wrong shape for a button in the chrome:
too big and too heavy next to the tab strip it hangs off. The menu is
the plain shell list it was before this branch — byte for byte, so
nothing about it needs re-reviewing — and the hosts, the search box and
the row leading to a host picker are gone with it.

Everything that came along to serve it goes too: the positional
NewTabWithShell command, the picker's palette delegate and its compact
row metrics, the standalone host palette, and the four strings they
needed. Connecting to a saved host is the palette's job again, which is
where it was and where it works.

* fix(switcher): size and weight the machine glyphs like the icons beside them

The two machine icons are drawn by hand; every other glyph in that
gutter comes from lucide. Ours were built to a tighter box — ink 19.3 ×
17.3 of a 24 grid against lucide's 22 × 20 — so at the same nominal
16pt the local machine rendered 11.5pt of ink beside a 14.7pt globe and
read as a size smaller. Both are redrawn to lucide's extents, which
also makes them agree with each other.

The local machine's glyph was muted while every remote one was full
strength, and while its own name was full strength either way. Beside
the machine under it that read as a disabled row rather than as the
computer you are sitting at. One weight for all of them now; the
"Other Machines" globe keeps its dimmer register, which belongs to the
muted section label it sits next to.

* fix(tabs): only a port stops the host head being cut off a title

Teaching `strip_host_prefix` that `deploy@10.0.0.5:2222` is an address and
not a titled directory was done by requiring the tail to start with `/` or
`~`. Two very common titles do neither.

Debian's stock bash title is `\u@\h: \w` — a space after the colon — so
`user@host: ~/work` would have stopped being cut at all, and every one of
those tabs would have gone from reading `~/work` to `user@host: ~/wo…`. And
tty7's own PowerShell integration writes `ann@BOX:C:/src` whenever the cwd is
off the home drive, which would have read `ann@BOX:C:/src` rather than
`C:/src`.

Key on the port instead, which is the thing that actually makes the string an
address: a tail of nothing but digits is kept whole, and everything else is
the path it always was. The space belongs to the head, so the tail is trimmed
on the way out.

* fix(ssh): keep a connection test off the cache, off a stale form, and clear about a changed host key

Three ways the new Test could answer for something other than the host in
front of it.

It dialled with `reuse: false` but still took the connection cache's slot
lock, which is held for the whole handshake. So a test stalled every Connect
to the same host behind a connection it was never going to leave them — and,
queued behind a session already dialling, spent its own budget waiting and
came back "connection timed out" about a host that answers fine. A test that
is not going to touch the cache has no business locking it: it now skips the
slot entirely, and only a reusing dial takes the guard it later fills in.

The form dropped a test result whenever a typed field changed, on the
grounds that the answer was about the host as it was a moment ago — but the
authentication method is a dropdown, and changing it left the green line
standing under a handshake the form would no longer make.

And a host key that has *changed* was reported with the same words as one
nobody has accepted yet. Those are not the same news: the first is a new
host, the second is the server presenting a different key than the one on
file. `SshTestNeed` now tells them apart and each gets its own line, in all
three locales.

Verified against a live sshd on localhost: refused port and unresolvable
name come back in milliseconds with the connect path's own message, a
password host comes back `NeedsInput { Password }` in 55 ms rather than
waiting out the two-minute prompt timeout, and two tests of the same host
back to back no longer serialize.

* chore(palette): drop the root flag the New Tab revert left behind

`grouped_root` was split out of `quick_connect_root` for the searchable host
picker on the New Tab menu, which was taken back out again. Every
constructor now sets the two to the same value, so the second one is a field
and a doc comment describing a list that does not exist.

* docs(changelog): record the SSH host form, pane names and connection test

Every user-facing change in this branch: panes named after their host, the
host form reached from the switcher and the palette, and Test.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:02:58 +08:00
Hongwei Qinandl0ng-ai f237cf5c48 fix(scm): answer a branchless push and name why a commit is refused (#545, #546) (#576)
* fix(scm): answer a push that has no branch to move (#545)

A click on Push at a detached HEAD died in scm_push's let-else without a
word, and it was not the only path that did: the key binding, the
palette entry and the follow-up half of "Commit and Push" — which lands
in scm_push after the commit has already succeeded — all share that
guard, so a compound verb read as pushed when only the commit happened.
git_data.rs says out loud why that cannot stand: a swallowed click on
Push looks exactly like a push that finished instantly, which is why the
busy slot toasts ScmNetworkBusy instead of dropping the click.

The sync tile made it worse by promising "Publish Branch" — upstream is
None at a detached HEAD by definition, and publishing is the one thing
it cannot do. The tile and the branch menu's Push item now disable
themselves with a tooltip that says why (Fetch works from any HEAD and
Pull already fails loud out of git's own error, so they stay), and the
guard itself toasts the reason for every path that can still reach it.

The guard also swallowed HeadState::Unborn — a branch with a name but
no commits yet — which now gets its own answer rather than the detached
one. Both dead ends are decided in one pure helper, pushable_branch, so
the tile, the menu and the toast cannot drift apart, with a test pinning
what each head state says.

* fix(scm): name the real reason a commit is refused (#546)

Committing with staged work but a blank message was answered with
"Nothing to commit" whatever the actual blocker, because scm_commit
hard-coded that one key for every disabled plan. The panel's own button
gets away with a shared tooltip because it is disabled and the reason
shows on hover; the palette entry and the key binding have nothing to
hover, so their toast was the whole feedback — and it pointed at the
index when the message box was the problem, sending the user staging
files they already staged.

The toast now carries the commit plan's own reason, the same key the
button's tooltip uses, and the commit_plan test pins the split:
whitespace-only message on staged work is ScmCommitNeedsMessage, a
clean tree is ScmNothingToCommit.

* fix(scm): ask pushable_branch for the tile and the menu too

The helper was introduced so the tile, the branch menu and the toast
could not answer differently about the same HEAD, but the first two were
still deciding off their own `detached` check — so an unborn branch,
which the helper already answers with "no commits to push yet", kept a
live sync tile promising "Publish Branch" and a live Push item, and
learned the truth only from the toast after the click. Both now ask the
same helper, and the tooltip carries whichever reason it gives back.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:01:03 +08:00
l0ng-aiandl0ng-ai bed3c856ed fix(panel): redraw the right panel's tab icons and put the tree glyph on the tile ladder (#578)
* fix(panel): redraw the right panel's three tab icons

The tiles render at 13px (`TILE_GLYPH`), and at that size the set had
three problems, none of them about style:

- `info` and `folder-closed` were both a rounded body split by a
  horizontal rule at nearly the same proportions, so the first and third
  tile could only be told apart by a notch a pixel and a half wide.
- `git-branch` drew its three nodes as filled r=2.5 discs — 2.7px across
  at render size — which buried the 2.1 strokes between them and left
  the glyph reading as three dots.
- The interior detail was too dense to survive: `info` carried two rules
  4.8 units apart and `folder-closed` a divider 3.2 units below its
  front edge, both under 2.6px once rendered.

Same three metaphors, same 2.1 pen, same rounded joins — only the
geometry moves. The nodes go hollow so the branch is drawn with one pen
rather than two. The crowded interior rules go, which leaves the folder
a clean body and makes the notch the thing that separates it from
`info`, rather than a second rule at a slightly different height. And
each mark grows into the height `panel-right` already occupies beside
them: the three had the width of that box and sat two units short of its
height, which on a row of tiles reads as three smaller icons rather than
as three different ones.

Measured as rendered ink, stroke included, on the 24 grid:

| glyph                               | before      | after       |
| ----------------------------------- | ----------- | ----------- |
| `panel-right` (unchanged reference) | 19.3 x 19.3 | —           |
| `info`                              | 19.3 x 17.3 | 19.3 x 18.1 |
| `folder-closed`                     | 19.3 x 17.3 | 19.3 x 17.7 |
| `git-branch`                        | 18.5 x 17.3 | 18.3 x 18.9 |

All three stay centred on 12, 12.

* fix(files): size the tree row's glyph off the tile ladder, not the rem one

Glyphs in this window come from the pixel ladder in `app.rs` —
`TILE_GLYPH` 13 for a chrome tile, `TILE_GLYPH_XS` 11 for the tiles a row
reveals on hover — while text comes from the rem ramp. The file tree's
leading folder/file mark was the one place drawing its size from
gpui-component's rem sizes instead, and those two systems cannot meet:
at the default `ui_font_size` of 16 the rem ladder offers `xsmall` 12 and
`small` 14 and nothing in between, so the mark was always a step under
the tab tiles above it — a speck beside a 14px name — or a step over
them, which is a row of content outsizing the navigation that owns it.

`ROW_GLYPH` is that size named once, in pixels, next to the panel's type
ramp and defined as the tab tile's own glyph, so the two now agree by
construction rather than by coincidence.

* fix(icons): put the tree's collapsed folder on the set's box

`folder.svg` is the one folder the set never squared up. It inks
19.9 x 16.1 where every other mark in the row is 19.3 wide, because its
top edge overshoots the box by 0.6 on the right — which also leaves the
whole glyph 0.3 off-centre that way, and 0.2 low.

Nothing showed while the tree drew it a step under the chrome. It is on
`ROW_GLYPH` now, the same 13px as the Files tab tile above it, and it
swaps in place with `folder-open` — already on the box — every time a
directory opens or closes, so the overhang is a shift in a fixed column
rather than a number in a file.

Same folder, same 2.8 corners, same 1.7 shoulder: the top edge gives
back the 0.6 it overshot and the body takes the 1.2 of height that puts
it on 19.3 x 17.3, centred on 12, 12, which is where `folder-open` and
the rest of the row already are.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:00:24 +08:00
l0ng-aiandl0ng-ai 3c5f14f71c fix(icons): draw the plus on the same optical bound as the rest of the set (#577)
* fix(icons): draw the plus on the same optical bound as the rest of the set

Every icon tty7 draws itself sits on one optical bound: 3.4..20.6 of a 24
viewBox, 19.3 units of ink once the round caps are counted. `plus` was the
exception at 6..18, 14.1 units — 59% of the box where the rest fill 77-80%,
and tighter even than the stock lucide glyph it replaced.

That was compensated at the call site instead, by handing the three chrome
tiles that carry a `+` a 16px glyph (`TILE_GLYPH_LINE`) where every other
tile in the same row gets 13. It worked where it was applied and nowhere
else: the port-forward section's `+` sits in a 24px tile, which has no
`_LINE` step to grow into, so it rendered at 7.6px of ink against the 10.5px
of the icons around it.

Redraw the asset onto the bound and the compensation is no longer needed
anywhere — the three call sites go back to plain `chrome_tile`, and the 24px
tile is fixed for free.

Scaling a glyph up buys stroke as well as extent, which is the part that is
easy to miss: at 16px the old art also drew 16/13 wider, so moving the `+`
to 13px thinned it by a fifth even as it got longer. A cross is two hairlines
with no fill to hide behind, so the weight had to come back in the art's own
`stroke-width`, and the value is eyeballed rather than derived — 1.0 CSS px
reads thin against the closed shapes beside it, 2.0 reads heavy-handed, and
1.75 is where it settled after walking the range on a 2x screen.

`TILE_GLYPH_LINE` stays for stock lucide `close`, which has the same tight
geometry and is not ours to redraw.

* fix(icons): keep the plus's extra weight to the 16/13 the call site was buying

The redraw put `plus` on the family's optical bound and then took its
`stroke-width` to 3.2308 — 1.75 CSS px at a 13px glyph, settled by eye
against `panel-left`, `panel-right` and `ellipsis` in the titlebar row.
Those are the three call sites the redraw was checked at, and they are
the three whose neighbours are closed shapes carrying solid fills. The
other three are not:

- Source Control's row strip draws `plus` (Stage) directly beside stock
  `undo-2` (Discard) at an 11px glyph, and pairs it with stock `minus`
  (Unstage) one group up. Both stock glyphs stroke 2. At 3.2308 the `+`
  came out 1.48 CSS px against their 0.92 — the stage button reads as
  the emphasised one in a strip where nothing is emphasised, and a
  stage/unstage pair stops looking like a pair.
- The switcher's Add SSH Host row draws it at 16px in the same gutter
  column as `search`, which is ours and strokes 2.1.
- Settings → SSH puts it next to stock `search` and stock `ellipsis`.

Reading a size heavy is the same failure as reading a size small, so
the weight goes to the one value that is derived rather than chosen:
2.1 × 16/13 = 2.5846, the family's own stroke times the scale-up the
three chrome tiles were already applying. At `TILE_GLYPH` that renders
at 1.4 CSS px, which is exactly what the titlebar `+` has always drawn
at — so nothing that was verified on screen moves, and the three tiles
that never had a `_LINE` step to grow into stop being overdrawn. The
arm ends move to 4.9846..19.0154 to keep the round-cap tips on whole
pixels at the new weight; the ink extent is unchanged at 16.6.

The test now derives the weight from `panel-left`'s own `stroke-width`
instead of carrying an eyeballed band, and checks both arms rather than
the vertical one — a cross edited on one axis passed before.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 14:49:33 +08:00
l0ng-aiandl0ng-ai 70ac7f201f fix(tree-sync): tell a window the name the machine gave the workspace it made (#604) (#613)
A workspace a window creates is created with a generated name, and that
name is what `tty7 ws ls` prints and what `tty7 ws rename` addresses. The
window itself was never told it. A client is left out of the deltas its own
ops raise, so the WorkspaceCreated delta carrying the name never comes back,
and both create paths threw away the copy the reply carried: `pull_or_create`
reduced the answered workspace to its tabs, and `pull_workspace` returned the
tree it had read before the create, which does not hold the workspace at all.

The mirror therefore held the workspace unnamed, the chip fell back to the
directory its shells started in, and the GUI and the CLI gave two different
answers to what the workspace was called — a user could read `verify-main`
on screen and not address it by that name. The first pull of the whole tree,
which a daemon restart, a rebuild and a plain relaunch all do, then produced
the name it had had all along and looked like a rename that stuck.

Both paths now carry the name they were answered with: the prime hands it to
the mirror, and the hydrating create puts the workspace it made into the tree
about to be installed. A workspace the machine really has no name for still
reads the directory it is working in, and a chosen name still wins.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:55:18 +08:00
l0ng-aiandl0ng-ai c159a86039 fix(ui): read a path's ~ off the machine the path is on (#580) (#607)
`abbreviate_home` measured every path against this process's own `$HOME`,
whoever the path belonged to. A remote pane sitting in `/home/deploy/app`
therefore read as `~/app` on a laptop that happens to log in as `deploy`,
and stayed spelled out on one that does not — the `~` naming the wrong
machine either way. #568 took the same borrow out of the file-link
resolver; this is the display half of it.

The home is now the caller's to name, because only the caller knows which
machine the path is on, and nothing here has to be asked for: a host
reports its home in the control handshake (`ControlHelloOk::home`) and
`HostLinks` already keeps it per host, so `path_display::home_for_host`
is a map lookup and never a round trip. `TerminalView::display_home` puts
a pane's own answer behind one call, and `Tab::leaf_title_and_home` reads
a title and its home off one leaf so the two cannot disagree.

Everything that draws a shortened path is on it: the Info panel's cwd, the
tab strip's label and tooltip, the sidebar's title and cwd lines, and the
switcher's workspace and tab rows. A path on a machine with no link — or
one a pane's shell has ssh'd away to, which no host here can answer for —
is shown in full rather than measured against a home that is not its own,
the same answer #568 settled on. A WSL pane gets its distro's home instead
of `C:\Users\…` for free, since it is a host like any other.

Tests: the borrow itself (a path with no home is left alone, and this
machine's home is not offered as a stand-in) at all three seams —
`abbreviate_home`, `short_title`, `display_path`. `ui::home`'s test no
longer has to set `HOME` on a process everything else is reading.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:09:06 +08:00
l0ng-aiandl0ng-ai 84d6fc223a fix(tree-sync): stop an abandoned Replace debt deleting the tabs it was pulling (#579) (#608)
`take_rehydrate` dropped an owed `Adopt::Replace` whenever the window had
any tabs, on the reading that the user had filled it in themselves. That
reading only ever fitted the one caller it was written for: Restart
Server, which empties the window before it resyncs. Every other
`Replace` — a daemon back as a new process, a restart handoff that was
refused, a remote server restarted, a layout delta that would not apply —
is ordered over a window that still holds its tabs, because those stale
tabs are the whole reason it was asked. The retry was abandoned on its
first attempt, every time, and the resync silently did nothing.

Abandoning it also left the window `informed`: still licensed to prune a
workspace whose layout it had never read. `start_prime` then refilled the
mirror from the machine, and the next `SyncScope::Full` diffed whatever
the window happened to hold against it — one tab opened over an emptied
window became `TabClose` for every tab on the machine, deleting those
panes' records while their shells kept running, with nothing tree-driven
left that could reach them. The damage #554 describes, through a door
#554 did not close, and the same on a remote window.

So the debt is now scoped to the layout it was owed over: `hydrate`
records the tabs on screen as it orders a pull, and only a tab that debt
never saw counts as the user moving on. And a `Replace` takes back the
`informed` licence up front, the way `on_preempted` does, so a window
waiting on a rebuild adds to its machine without pruning it until the
pull lands — however the debt ends.

Pre-existing since #472 (af3928d, e4bd49c); both PR #564 and #569 saw it
and left it alone because the rule is shared with the remote resyncs.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:02:20 +08:00
l0ng-aiandl0ng-ai 81c1e3d5e1 test(keymap): ask the keymap what is bound, and cover the reload that binds it (#582) (#609)
The keymap tests asserted against `bound_keystrokes`, a `#[cfg(test)]` mirror
of `action_bindings` that #571 left behind when it removed the `NoAction`
retire loop — the last production reader of it. A mirror is a second
implementation of the table, so those tests could agree with it while the app
bound something else.

Each one now builds a `gpui::Keymap` from `action_bindings` and performs the
lookup gpui performs on a keypress: typing this chord in that context runs that
action. The paste test gains the other half it never asserted — that a terminal
chord does nothing outside a terminal — and the context test is renamed for
what it now checks. `bound_keystrokes` is deleted.

The watcher had no coverage at all, so the one thing #548 is for — hand-editing
config.json and having the new chord fire without a restart — was pinned
nowhere. Its tick moves out of the closure into `apply_reloaded_config`: same
statements in the same order, taking the load result as an argument and
returning whether it rebound, which the watcher ignores. Three tests drive it
against a live keymap — a reload that binds Ctrl+Alt+9 to SplitRight makes that
chord dispatch it, a reload that moves no binding does not rebuild the map, and
a quarantined reload rebinds nothing, because the global config is deliberately
not replaced on that path and the user's keys have to survive a typo.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:01:31 +08:00
Hongwei Qinandl0ng-ai 664b766698 fix(settings): split Shell Arguments like a shell, quote them on the way back (#551) (#573)
* fix(settings): split Shell Arguments like a shell, quote them on the way back (#551)

The field split on raw whitespace, so `-c "echo hi"` became four argv
fragments with the quotes still attached, and it silently rewrote config
too: `build_shell_inputs` refilled the field with `args.join(" ")`, which
cannot spell an argument containing a space, so a legal
`"args": ["-c", "echo hi"]` in config.json re-committed as three argv on
the next blur without the user typing anything.

Parse with shell-words rules instead and quote each argument on the
refill, so field text and the argv array round-trip losslessly. An
unbalanced quote cannot become argv at all, so commit refuses it and the
row explains why under the input — the proxy field's pattern. The field
description in en/zh/ja now says quoting works.

Program gets the milder half of the same treatment: a bare command that
detection (a PATH probe) never saw is almost always a typo like `pwsh7`
that today only surfaces when a pane fails to open, so the row warns
under the field. It never refuses — the field stays free-text so a shell
detection missed remains reachable — and anything spelled as a path is
taken at its word. The comparison reuses core's `same_shell_program`, so
"known" here means exactly what the new-tab menu dedup means.

shell-words was already in the tree via portable-pty, so the direct pin
adds no new code.

* fix(settings): split Shell Arguments as argv, not as POSIX source

Review pass over the #551 fix. Splitting with `shell-words` bought the
quoting contract at the price of two silent rewrites of its own, both the
same shape as the bug being fixed: a backslash outside quotes is a POSIX
escape, so `--dir C:\Users\me` committed as `C:Usersme` on the platform
where that is how a path is spelled, and `#` opens a comment, so
`--tag #1 --verbose` committed as one argument. The refill was noisier
than claimed too — `shell_words::join` quotes on `=`, `~`, `*`, `?` and
`[`, so an existing `--color=auto` came back as `'--color=auto'`.

Nothing here is a shell: the field is a text spelling of an argv array
that goes to `CommandBuilder` directly. So split and join are now a local
pair sized to exactly that job — quotes group, `\"` and `\\` inside double
quotes escape, everything else is a character — and the direct
`shell-words` pin goes away again. They are exact inverses, which is what
`config.json` needs, and the test walks the round trip over the cases a
space-join cannot spell plus the two above.

Drops the Program nudge. `shells::inventory()` inserts the *configured*
shell into the inventory it returns, so `pwsh7` is in `self.shells` from
the next refresh onward: the warning could only ever flash between the
commit and the refresh landing, and never appeared at all on a later visit
to Settings. Its test passed because it hand-built an inventory that
version of the value could not be in. Making it true needs core to say
which rows were detected rather than configured, and that is a serialized
protocol struct — too much for a nudge the issue itself called the milder
half.

Refusing the arguments no longer drops the Program typed or picked beside
them: the stored argv stays as it was, which is what "this value was not
saved" already told the user, and the shell picker works again while the
arguments field is mid-edit.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:32:18 +08:00
Hongwei Qinandl0ng-ai 1987766a14 fix(daemon): rebuild the window from the tree when a restart handoff fails (#554) (#564)
* fix(daemon): rebuild the window from the tree when a restart handoff fails (#554)

Restart Server clears the window's tabs before attempting the handoff, and
the Err branch then only showed a reason on the home page. A refused
handoff leaves the daemon exactly as it was — still serving the panes the
window just dropped — but every restore path is tree-driven, and the next
sync of the emptied window diffed into "close every tab" against the
mirror: pane records deleted under shells still running, or the whole
workspace removed if the user simply closed the window first, right after
the dialog promised nothing would be interrupted.

The Err branch now runs the same invalidate + resync the Ok branch runs,
pulled out as tree_sync::resync_after_local_daemon_change. Where the
daemon really is gone (an exec that never re-listened), the pull misses
and the rehydration debt is what keeps the empty window from being pushed
up as the layout. The failure is also toasted, since a successful resync
takes the window off the home page where the reason was shown.

* test(tree-sync): anchor the emptied window giving up its mirror

The fix for #554 rests on one property that nothing asserted: after a
restart that failed, the window must stop speaking for the machine. An
emptied window still `informed` over a `Primed` mirror is the shape that
does the damage — the next sync diffs it into "close every tab", and
closing it authorizes a `WorkspaceRemove` outright, while every shell it
named is still running on a daemon that never went anywhere.

The handoff itself is UI-plus-daemon integration and stays uncovered, but
the guard is not: the pull drops the mirror, clears the ops queued off the
emptied window, and takes the workspace back out of disposable range, all
synchronously and before any link is needed. Pinned on
`resync_window_from_tree`, the per-window step both of the helpers above it
work through — which of them a caller picks decides whether the link is
dropped first and which windows are walked, not this.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:22:20 +08:00
Hongwei Qinandl0ng-ai 8071eddb5b fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550) (#572)
* fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550)

The settings steppers and the Ctrl+=/Ctrl+- keys clamped font size to
6-48 and line height to 1.0-2.0, while `sanitize` allows 4-256 and
0.5-4.0. A value inside the config range but outside the GUI's got
pushed the wrong way by a single step — `font_size: 50` shrank to 48 on
"+" — and `set_font_size` writes the result back to the file, so one
misclick permanently changed a value it only meant to nudge. The bounds
move into tty7-core beside `sanitize` (the `ui_font_size` precedent),
one shared range for validation, the steppers, and the keyboard path.

The scrollback and notify-threshold preset rows had the matching
display bug: the highlight matched a *range*, so a hand-set 5000 lit up
"10,000" and 20s lit up "30s", and clicking that cell silently
overwrote the real value with the bucket's. The segmented control now
highlights a bucket only on an exact match and otherwise shows a
"Custom (N)" cell that names the live value and is not a button.

* fix(ui): name a custom preset the way the cells beside it are written

Review follow-up on #550. The "Custom (N)" cell rendered the raw integer, so
a documented `scrollback_limit: 50000` read "Custom (50000)" between cells
reading "10,000" and "100,000" — the one number on the row not written like a
count. It is grouped now, and the presets and their labels are one pair of
lists each, checked against each other, so a cell cannot come to show one
number and write another.

The bucket match moves out of the render bodies into `preset_choice`, which
is what makes the exact-match rule the issue asked for testable: the presets
the default lands on, the 50,000 the example config in
`docs/reference/configuration.mdx` carries, and 20s on the notify row.

The core test claimed to pin "the GUI steps within the range sanitize
allows", but only asserted that sanitize agrees with the constants it is
written in terms of — true by construction, and its line-height case took the
reset path rather than the clamp, so it passed without touching
LINE_HEIGHT_MIN at all. It now pins the published numbers themselves, the
clamp in both directions, and the two values the issue was reported with.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:08:45 +08:00
Hongwei Qinandl0ng-ai 5dd60a6660 fix(ui): rebuild the keymap when a hand-edited binding reloads (#548) (#571)
* fix(ui): rebuild the keymap when a hand-edited binding reloads (#548)

The config watcher reloaded locale, theme, menus and shells but never
the keymap, so a hand-edited `keybindings` entry appeared in the
settings list off the live global while the key itself stayed dead
until restart — the settings page showed a binding gpui would never
dispatch.

Two changes make driving a rebind from the watcher safe. First the
rebind clears before it binds: gpui `Keymap::add_bindings` only ever
pushes — it never dedups and nothing retires a binding — so the old
append-only rebind leaked one full table per call, and every keystroke
walks the whole map. `rebuild_keymap` now replays the action bindings
plus the fixed terminal/modal bindings onto a cleared map, and `init`
shares it.

Second the watcher gates on it. It fires for every write under the
config dir — including the apps own `save()`, which a sidebar drag or
a palette open triggers — so it compares the `(keybindings,
keybinding_preset, prefix)` triple before and after the reload and only
rebinds when a binding actually moved, keeping a no-op save from
churning the keymap. Tests pin the triple only moving on a real
binding change and the map not growing across repeated rebinds.

Per the review this lands after #537 (the watcher keeping the old
config on a parse failure): without that, a broken edit would swap in
defaults and this rebind would then wipe the users keys from the live
keymap too. The unknown-action surface and the prefix-conflict
detection the review also scoped are follow-ups.

* fix(ui): carry the inherited bindings through a keymap rebuild

`rebuild_keymap` clears the whole map, and the map is not tty7's alone:
`gpui_component::init` runs first and installs the `Input` context's
editing table, the list and menu navigation and the escape that closes a
dialog, with no entry point to install them a second time. Clearing and
replaying only tty7's bindings dropped all of it — at `init`, so every
text field in the app lost backspace, the arrows, enter and escape from
the first frame, and the test that pinned the map's size could not see it
because it measured after `init` had already cleared.

Snapshot whatever is in the keymap before tty7 binds anything and lay it
back down first on every rebuild, in the order it was added, so tty7's
bindings still win. Drop the `BoundKeystrokes` global with them: the
retire-one-`NoAction`-at-a-time path it fed is gone, and its last reader
with it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:59:23 +08:00
Hongwei Qinandl0ng-ai e1efae2809 fix(ui): read the Info panel agent row off one pane, and shorten Windows paths to their leaf (#543, #544) (#570)
* fix(ui): read the Info panel agent row off one pane, and shorten Windows paths to their leaf (#543, #544)

Two rows in the Info panel were misreading a split tab or a Windows
path.

The agent row took its name from `tab.agent` (the first leaf with an
agent) and its status from `tab.agent_status` (the most urgent across
the whole tab); the two resolve independently, so a split tab running
two agents could show one pane name beside the other pane state — a
row no leaf ever had. Both now come from the detail pane when it has
an agent, falling back to the tab aggregate only when the focused leaf
has none, so the row holds while focus sits on a plain shell but never
splices.

The cwd row split the path on `/` only, so a backslash-spelled path —
any agent-reported cwd (`agent_session.cwd` arrives as `C:\…`), a cmd
pane, a shell-integration-off pane, or the seed cwd before the first
prompt — elided its tail and hid the directory own name. The split
now takes the last of either separator, and is not cfg-gated: the panel
shows remote paths, so a Windows build describes Unix paths and vice
versa. The `~` shortening moves into a shared `path_display` helper
reading `USERPROFILE` as well as `HOME` and comparing with separators
normalized and case folded; the tab strip `abbreviate_home` and the
home picker `display_path` had the same HOME-only miss and now use
it too. Tests pin backslash, mixed-separator, drive-root and UNC shapes.

* fix(ui): hold the agent row's fallback to one leaf, and stop the home test leaking

Review follow-ups on the #543/#544 pair.

- The fallback branch still spliced. When the focused leaf carries no
  agent the row fell back to `tab.agent` + `tab.agent_status`, which is
  exactly the pair the fix set out to break up: the first leaf with an
  agent, beside the highest urgency anywhere in the tab. A three-way
  split with focus on a plain shell could still read `Claude · Working`
  off two different panes. `Tab::agent_row` now picks the most urgent
  agent leaf and answers both halves out of it; `agent_status` is that
  pair's status, so the tab strip's badge is unchanged.

- `path_display`'s test home was a process-global that no test ever
  cleared, so once one of these tests ran, every later `abbreviate_home`
  in the binary read the pinned home — including `ui::home`'s own test,
  which sets `HOME` and expects to see it. Ordering decided whether it
  passed. The comparison moves into `abbreviate_under(path, home)` and
  the tests hand their home in; nothing global is left to leak. Adds
  the trailing-separator home and non-ASCII component cases the byte
  boundary reasoning turns on.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:38:49 +08:00
Hongwei Qinandl0ng-ai 557bfd3f3a fix(ui): resync local windows when the daemon comes back as a new process (#553) (#569)
* fix(ui): resync local windows when the daemon comes back as a new process (#553)

From the client's side a killed daemon is indistinguishable from one
whose shells all exited at once — its DeathReporter says nothing while
it shuts down, and a taskkill says nothing ever — so the window kept
showing every pane with its last title, and the reconnect then pushed
that dead layout back up as the new daemon's truth. On Windows a
force-killed daemon can even leave the shells themselves alive but
permanently unreachable.

The control handshake already carries an instance id per server
process, and the remote path already compared it (server_restarted);
the local link never did. Now LocalLink remembers the hello instance —
across invalidate(), deliberately, or the restart path's own first move
would blind the comparison — and a changed instance on reconnect routes
every local window through resync_after_local_daemon_change instead of
on_link_up: the dead link is dropped first, then hydrate rebuilds from
the machine tree and each pane comes back from its scrollback snapshot
with the "new shell" banner.

note_instance moves to tree_sync as the shared comparison (empty
instance = server predates the field, never a restart, never
overwrites), with the remote callers and their tests repointed. The
restart-server action now uses the same helper instead of open-coding
invalidate + resync, which also fixes it rebuilding only the current
window when several local windows are open.

Per the review, the title wording ("process exited" while the link was
down) and the missing Respawn menu item are a separate PR: the current
wording is pinned by a_dropped_link_does_not_claim_the_process_exited
and changing it is its own decision.

* docs(changelog): entry for the local-daemon reconnect resync (#553)

* fix(ui): keep the reconnect's own link when the daemon came back new

The reconnect installs the new daemon's client and then, on a changed
instance, called the helper that starts by dropping it. The link the
handshake had just proved good was thrown away, every window's pull went
out with nothing to send it on, and the layout had to wait for the next
tick to connect a second time.

That is not only wasted work. `hydrate` gives the link fifteen seconds to
come back and then owes the window a `Replace` — and a `Replace` is
abandoned the moment the window has tabs, which in this scenario it
always does: the dead ones still on screen are the whole bug. So a slow
second connect turned the resync into nothing at all, silently.

Split the helper: `resync_after_local_daemon_change` still invalidates
first, for the restart-server path whose link really does point at a
server it killed, and `resync_local_windows_from_tree` is the half the
reconnect wants, with the fresh link left in place.

The two note_instance tests that moved here with the function now say
what tree_sync's own tests say; the one that is this module's — the slot
is per host — stays.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:05:04 +08:00
Hongwei Qinandl0ng-ai 14ab96284c fix(config): quarantine an unparseable config.json instead of overwriting it (#537) (#565)
* fix(config): quarantine an unparseable config.json instead of overwriting it (#537)

A config.json that failed to parse was replaced by in-memory defaults with
only a log line, and the next write of any setting — a dragged sidebar
divider, Ctrl+=, anything saved in Settings — serialized those defaults over
the file wholesale: one typo, and every hand edit was gone. views.json and
machine.json already kept a corrupt file aside for exactly this reason;
config.json was the one that did not.

A failed parse now parks the file's contents as config.json.corrupt and
hands back defaults marked quarantined — a non-serialized flag that makes
Config::save refuse to run, so all twenty-plus write call sites are covered
without touching them. Config::load_with_outcome returns the verdict beside
the values (Parsed / Absent / Quarantined) so the hot-reload watcher can
tell a broken file from a missing one: a broken one keeps the settings the
app is running on instead of swapping defaults in, and says so in a toast;
a startup on defaults after a quarantine says so too. A read failure (not
merely a parse failure) warned nowhere at all — it logs, and suppresses
writes the same way, without parking a copy there may be nothing readable
to take.

* fix(config): keep one copy of a broken config, and one word about it

`Config::load` runs on every pane spawn and every palette command, so a
file left unparseable was quarantined again and again: opening a couple
of tabs filled the config directory with eight identical .corrupt files
and then overwrote the first. A copy that already holds those bytes is
the copy the call would make, so it is not made again.

The hot-reload watcher covers the themes directory too, and returning
early on a broken config.json took theme hot-reload down with it and
re-announced the same breakage on every theme save. Themes now reload
either way, and the toast speaks once per breakage.

A file that cannot be read parks nothing, so it no longer reports itself
as quarantined and no longer sends the user after a .corrupt file that
was never written; and the reload toast no longer claims settings stop
saving, which is true at startup but not mid-session, where the running
config is kept and stays writable.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:04:03 +08:00
l0ng-aiandl0ng-ai d343fd8a13 feat(links): open file links in tty7, resolved on the pane's own host (#568)
* feat(links): open file links in tty7, resolved on the pane's own host

A clicked file path now opens in the built-in editor at the line and
column the link named, and the Files panel reveals it; a directory link
opens the panel on that directory. Settings -> Terminal -> Links -> Open
files with picks between the built-in editor, the OS file association
and a command, migrating anyone who had already set link_file_command.

Detection is split into a filesystem-free candidate parser and a probe
callback, so a pane whose paths live on another machine resolves them
there instead of against the local filesystem -- an absolute path used
to open this machine's copy silently. A pane running ssh typed into a
local shell can answer for neither side and no longer offers file links
at all.

Relative paths are measured from the directory the work is happening in
(the agent's, not the shell's kernel cwd) and then from the repository
around it, and a path that matches nothing under either now says so
instead of the click doing nothing.

* fix(links): keep a remote path off the local openers, and off a dead end

Review follow-ups on the file-link work.

- A file resolved on another machine now opens in the built-in editor
  whatever `link_file_open` says. Under `system` or `command` the path was
  handed to a local `open` / `code --goto`, which threw away the resolution
  just done on the pane's host and silently showed this machine's copy — the
  same bug this branch set out to fix, left live for two of the three modes.
  A directory outside every tree root says so instead of opening a local file
  manager on a path that belongs to the far side.

- `flush_link_probes` takes the host before it takes the wanted paths.
  `take_wanted` moves them into the in-flight set on the promise that a call
  is carrying them; a host that had gone away broke that promise for good and
  left those paths permanently unanswered — no underline, and a click that
  says nothing.

- `~` no longer borrows this machine's `$HOME` for a pane whose paths are
  elsewhere. A cwd outside `/home` and `/Users` used to fall back to it, so
  `~/.zshrc` on a Linux box became `/Users/me/.zshrc` and was asked about —
  and possibly answered — over there.

- An unresolved absolute or `~`-rooted path no longer claims it was looked
  for under the pane's directory. It never was: roots are only for relative
  paths.

- A pending tree reveal counts down whether or not its row was found. A row
  that never reported bounds kept the request alive for good, re-issuing a
  scroll on every render and holding the column against a hand scroll.

- The repo root comes from `GitStatusCache` when the git-status probe has
  already asked about that directory, rather than a second round trip.

Tests: the migration `link_file_open` exists for (an old config with a
command lands on Command, one without on the editor), a probe with no host
staying wanted, and `~` refusing this machine's home for another one.

* test(links): only claim a leading slash is absolute where it is

`is_rooted` asks `Path::is_absolute`, the same question `FileCandidate::paths`
asks before it decides the roots do not apply — and on Windows `/etc/hosts`
answers no to both. The predicate is consistent; the assertion was not, so it
now lives in a unix-gated test of its own next to the untouched one.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 22:48:16 +08:00
l0ng-ai c7207e990e fix(tabs): stop a drag crashing the strip once its chips overflow (#563)
`visible_chips` renders only the window of chips that fits, but the drag seeds one slot per tab and lets each chip's canvas fill its own. Every chip scrolled off the strip therefore kept the `Bounds::default()` it was seeded with, and `held_origin` read those zeroes as a real origin — clamping the dragged chip to an inverted range, which panics. The first drag of any tab killed the window as soon as there were more tabs than fit.

A zero extent now means "off screen" rather than "empty" everywhere the geometry asks: `held_origin` spans only measured slots and floors its upper bound, `target` leaves an unmeasured slot on the side of the dragged chip it started on, and `displacement` stops summing phantom extents into the slide animation.

The sidebar surfaces were never affected — they size their slots to what they draw, so every slot they allocate gets measured.
2026-08-12 19:46:10 +08:00
ARNO 6f6eee7eda fix(switcher): give a machine name its own line above the address (#560)
A machine group header in the workspace switcher drew the machine name and
its endpoint (`user@host:port`) on one row. The endpoint was a fixed-width
flex item — up to `TAB_PATH_W`, never shrinking — so it claimed its space
first and the name, `flex_1` off a zero basis, collapsed into whatever was
left. A machine whose name runs long read as a stub: `y...`, `GAEM...`.
The "other machines" rows paired label and address the same way and
squeezed the same way.

Both now use the two-line shape the workspace rows under them already
use: the name on the main line, backed by the full row width and truncated
only when the name alone cannot fit, and the endpoint plus link status
underneath in the dimmer secondary style. Rows with neither endpoint nor
status — the local group — stay single-line, and header height grows with
its content (`min_h` plus padding) instead of holding a fixed 34px.

On the second line the endpoint keeps its natural width and the status
word yields, so the address stays readable; `TAB_PATH_W` has no callers
left and is gone.
2026-08-12 17:08:13 +08:00
l0ng-aiandl0ng-ai 100904a313 fix(switcher): name another workspace's tabs from their terminal titles (#558)
A window names its own tabs from its live terminals' OSC titles; every
other workspace it lists it reads out of the machine tree, which recorded
each pane's foreground process name and never its title. So the naming
fell through to the agent, and switching workspaces — which happens in
place and drops the terminals the window was reading — turned the tabs of
the workspace just left into a column of identical "Claude Code" rows.

The daemon now sniffs OSC 0/2 and records the title beside the pane's cwd,
capped at 256 characters, and `TabLabel` ranks it second only to a name
someone gave the tab. The switcher puts it through the same abbreviation
the tab strip uses, so a shell's `user@host:~/dir` reads `…/dir` in both
places, and in a split the pane running an agent names the tab rather than
whichever shell happens to be first. `tty7 tab ls` and workspaces on a
remote machine were reading the same missing field and are named the same
way now.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 13:52:17 +08:00
l0ng-aiandl0ng-ai 9101200170 fix(search): keep highlights on their text while a pane is printing (#559)
A match point is a line of the grid it was scanned from, so every line
that scrolls off the top leaves each highlight washing the row its text
has just left. Slide the stored points along with the text on every
wakeup — their absolute rows do not move, so re-deriving each line from
its anchor is enough, and it reads nothing off the grid.

The rescan behind that was purely debounced, so a pane that never pauses
— an agent streaming a reply — never got one at all: the count froze and
highlights sat on text that had since been rewritten in place. Cap how
far output can push the deadline out, at four windows.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 13:32:54 +08:00
l0ng-aiandl0ng-ai d81bb5e617 fix(scm): put the Source Control panel back on the interface font scale (#557)
* fix(scm): make the commit body's fold actually fold

Show more / Show less flipped `body_expanded` and relabelled itself
correctly the whole time. What never moved was the body.

`line_clamp` only limits the *wrapped* lines within one logical line:
`shape_text` splits the string on `\n` first and shapes every hard line it
finds, feeding the clamp to `layout_wrapped_line` per line rather than to
the document. A commit message is the one string this panel draws that is
always hard-wrapped, so a clamp of four over a thirty-line body laid out
all thirty, and the fold was a label with nothing behind it.

Fold by clipping a height instead. The body's leading is spelled out — 18px
is what `phi()` already resolved 11px type to, so nothing moves by naming it
— and four of those plus the padding is the folded height. The string is
truncated to `BODY_LINES` as well, which keeps the shaper off lines nobody
can see; the height is what guarantees the fold, because in a column this
narrow those four lines can each wrap again.

Guarded by a test that clicks the row in a real window and measures the
body's bounds on both sides of both clicks. The flag alone proves nothing
here — it was right all along.

* fix(scm): put the Source Control panel back on the interface font scale

The Source Control tab has been a step smaller than the rest of the right
panel since the day it was written, and did not respond to `ui_font_size`
at all.

Nothing chose that. The branch was cut from main at 00:51 and copied
`right_panel.rs`'s ramp as it stood — 12/11/10.5, in px. At 14:09 the same
day the interface font scale landed on main: it moved that ramp to
14/13/12/11 and onto rems, so every size in the window became a multiple of
`window.rem_size()`, which the root sets from `ui_font_size`. It rewrote the
three files drawn inside this panel that existed on main at the time; `scm/`
was still on a branch, so nothing rewrote it. The merge the next day had
nothing to conflict over, because the two sides had touched different files.

So the panel's *primary* text sat at the size its neighbours use for
*secondary* text, and the Files tab beside it — `text_sm()`, the same rem
under another name — rendered a step larger again. `git_badge` and
`info_chip` are shared, so a file row already mixed a rems status letter with
a px path.

Every size a reader can read is now `rems(TEXT)` and its four siblings.
`px` stays on what type sits inside: row pitch, tiles, the marks. Those
geometries move with the type they hold — the file row is 26 around a 21px
mono line, a graph row 24 around a 23px subject, and the history section's
resting and minimum heights grow to match, because both are counted in
commits and holding the pixels would have bought the file list 40px by
quietly showing two fewer. The lane gutter is untouched: a taller row against
the same lane width steepens a merge's diagonal, which is the direction that
pitch was guarding.

Three smaller corrections fall out of it. The branch trigger is `.small()`,
not `.xsmall()` — the file names under it moved to `TEXT_MONO`, and a
`.xsmall()` label would have left the branch quieter than the paths it
scopes. The commit body's fold is in rems now, so a clip that used to land on
a line boundary still does when the rem moves. And `detail.rs` reads
`panel.rs`'s row constants instead of holding a matching pair kept in step by
a comment.

There is one prior verdict this reverses. `detail.rs` recorded that a
14/12/11.5 ramp had been tried "through the whole panel" and read as too big
for a 260px column and too loud beside the graph. That pass ran at 14:35 on a
branch where everything else was still 12/11/10.5, so what it measured was
this view standing a step above its neighbours — which is the rule it was
defending and which this change keeps. The neighbours have moved; being at 12
is now the thing that reads as pasted in.

Guarded by a test that greps `scm/`'s own source for `text_size(px(`.
Assertions over the constants would only prove the constants agree with
themselves; what went wrong was a literal at a call site, so that is what it
reads.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 13:31:36 +08:00
l0ng-aiandl0ng-ai f792cfb8c1 fix(switcher): drop the stray guide rail under a remote machine (#536)
Expanding a remote machine in the workspace switcher drew a 1px vertical
line down its rows, positioned left of the workspace avatars. Local groups
never drew it, so it read as a stray line rather than an indent guide, and
it stopped short of the last row's midpoint, leaving a dangling stub.

Remote rows now indent exactly like the SSH-host rows below them.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 11:41:34 +08:00
webdev 4da3868797 feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443
2026-08-12 10:52:35 +08:00
l0ng-aiandl0ng-ai b7196ae49a Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show

The panel's Session table rendered every fact the same inert way, and its
two actions sat in a strip of their own under the whole list — unlabelled,
four rows below the path they acted on. Rows now carry their own shape:
`changes` is the sidebar's green-and-red `+N −M` and opens the same diff
overlay under the same setting, an agent wears the same status dot its tab
does, and what a row can do appears at the end of it on hover, in the strip
Source Control rows already use. A port row hands over its address instead
of leaving it to be retyped, and the lit panel tile closes the panel the way
every other activity bar does.

* fix(right-panel): answer for the row the pointer is actually on

Review of #531 found the new rows promising more than they could keep.

Port rows keyed their element id on the port alone, but a port is only
unique with its pid — a pre-forking server puts one row per worker on
screen, and gpui handed them a single interactive state, so a click on
one lit the tooltip and the pressed fill on all of them.

The `changes` counts were read off `Tab::git_status`, which resolves a
split tab to its *first* leaf, while the click target came from
`detail_pane`, which resolves it to the *last focused* one. Inert text
could disagree harmlessly; a button could not, and clicking `+2 −0`
opened another pane's repository. Both now come from the pane the rest
of the rows describe.

A port is only `localhost` if localhost reaches it. `lsof`'s bind
address was parsed and dropped, so a server on `172.17.0.1:8080` was
offered as `localhost:8080` — a refused connection, or somebody else's
service. `PortEntry` carries the address (`serde(default)`, so an older
daemon still answers), and the wildcard and loopback binds keep the
`localhost` spelling anyone would type.

The browser tile hung off `remote_context()` — where the *shell* is —
though the ports come from the pane's own process tree either way. It
hid the tile on the one pane where it works, a `ssh -L` forward listening
on this machine. It is about the host now.

The action strip is opaque and pinned to the row's right edge, so on the
working-directory row it covered the leaf that the head-first elision
exists to preserve. The value holds that width back for good rather than
on hover: taking it on hover would re-elide the path under the pointer,
which is the pixel-shifting the strip is absolutely positioned to avoid.

Also: the counts were `flex_1`, so the whole rest of the line was the
button and empty space underlined numbers it was nowhere near; the agent
pip was pinned in pixels inside rem-sized text and slid off its line at
any interface scale but 100%, and drew Waiting as a thin ring where the
tab strip punches a hole in a filled dot — one rule, two dialects; the
panel-toggle chrome tile, which on macOS lives inside the panel it
closes, still dropped focus into the destroyed element and left ⌘J
dead; and `scm/detail.rs` kept a third copy of `ROW_INSET`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 09:58:47 +08:00
l0ng-ai 4b7719ba5f fix(watch): stop a filesystem watch feeding itself on Linux (#523)
On Linux, an idle window with a repository open ran `git status -uall` about
2.6 times a second, forever. `notify`'s inotify backend subscribes with
`WatchMask::OPEN`, so every `open(2)` under a watched directory is an event —
and `git status` opens `.git/index`, `.git/HEAD` and `refs/heads/*`, all of
which the source control watch covers. The read that answers "did this
repository change" was itself an event saying it may have changed, so each
answer scheduled the next question. Measured on the Linux runner: 78 debounce
bursts in 30 seconds, from one real change.

`Debounce`'s doc argued this was impossible because `GIT_OPTIONAL_LOCKS=0`
stops `git status` writing the index back. That covers writes; `IN_OPEN` fires
on reads. macOS FSEvents has no equivalent, which is why it was invisible on
the machine it was written on.

`is_content_change` drops `Access(Open | Read | Close(Read))` and keeps
`Access(Close(Write))`, at both the host watch and the config hot-reload watch.
Guarded by a host conformance case rather than a platform test, verified red on
the Linux runner with the filter removed.

Also here, and how the above was found: the `render_idle` tests counted frames
across a window they advance a virtual clock over, while the pane runs its own
git pipeline off a 300ms timer on that same clock that nothing advanced during
settle — so the measurement set off the pane's first `git` run and raced its
landing. `test_window::quiesce` settles both clocks, and the kernel's, before
counting. That turned #523 from a 1-in-50 flake into a deterministic failure,
which is what made the watch loop findable.

Third: `ssh_config::an_alias_added_to_an_included_file_is_seen_without_touching_the_root`
(from #528, already on main) failed on every Windows run of this branch. The
cache keys on mtime and the test writes twice inside one ~15ms Windows clock
tick, so the cache was right to say nothing changed. Test-only; the poll behind
it runs at 4Hz and real edits arrive at human speed.

Closes #523.
2026-08-12 01:00:52 +08:00
l0ng-aiandl0ng-ai e4bd49c39c fix(launch): restore the layout when a launch names a directory (#529)
"Open in tty7" from Explorer, and `tty7 <PATH>`, skipped the session
restore whenever no window was already up — `restore_session` said
nothing about it, the condition was simply "an explicit path was given".
The folder arrived as a lone blank terminal in a brand-new workspace and
the previous tabs were left behind: still running on the server, still
`open: true` in views.json, and reachable only through the switcher.

With a window already up the same menu entry had always just added a tab
to it, so one entry had two meanings depending on whether the GUI
happened to be running. Both shapes now restore first and open the
folder as one more tab.

The folder cannot be handed to the new window as its first terminal:
`Adopt::IfEmpty` declines to adopt into a window that already has a tab,
and the pull would then push that one tab back as the whole workspace —
writing the layout it was restoring off the machine. So it travels with
the hydration and is opened by whichever attempt settles it.

A path still declines to follow the layout onto a remote workspace and
starts a fresh local one there, which is the case the old blanket skip
was really guarding: the directory it names is a path on this computer.

Fixes #527

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 23:28:53 +08:00
l0ng-ai a2eab07f8d fix(ssh-config): watch every included file an alias was parsed from (#528)
The cache behind `alias_still_resolves` keyed on the mtime of
`~/.ssh/config` alone, and `Include` leaves the including file's timestamp
untouched — so an alias put back in the file it lived in went on reading
as gone, parking its workspaces with no retry and no error. Every file the
parse reads is watched now, plus the root even when unreadable.

Two tests that failed on CI for reasons outside the code they cover go
with it. `pane_history` waited on the seed file existing, but the snippet
seeds through a redirection, which creates the file before `tail` fills
it. `daemon::singleton` asked for the seat back the instant it dropped it,
and a neighbour's fork keeps an inherited descriptor referencing the same
flock until its exec.

Closes #524. Closes #525.
2026-08-11 22:53:29 +08:00
l0ng-aiandl0ng-ai ba6e4432ab fix(files): follow a coding agent into its git worktree (#526)
The Files tree rooted itself at the foreground process's cwd, read out of
the kernel (`proc_pidinfo` on macOS, `/proc/<pid>/cwd` on Linux). That is
the right answer for a shell and the wrong one for a coding agent: moving
into a git worktree does not `chdir`, so `claude` that entered
`.claude/worktrees/feature` still reports the directory it was launched in,
and the tree stayed rooted in the main checkout for the rest of the session.

tty7 already knows better. An agent's hooks report their own cwd, which
rides the OSC stream into `AgentSessionState::cwd`, and the git-status poll
has folded that over the process cwd since remote workspaces landed. Three
panels read the result — the pane's cwd row, the tab sidebar's path and the
source control panel — each with its own hand-rolled
`git_status_cwd().or_else(|| …cwd())`. The file tree was the one consumer
that never got the memo, which made the disagreement visible inside a single
panel: the cwd row said the worktree, the tree below it said the main repo.

Give the pattern a name — `TerminalView::effective_cwd`, plus an
`effective_host_cwd` for callers that hand the path to a `Host` — and route
all four through it. Behaviour is unchanged for the three that already
preferred the agent; the file tree now agrees with them. A pane with no
agent, or one whose turn just ended, falls back to the process cwd exactly
as before, so a stale worktree can never outlive the session that named it.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 22:24:09 +08:00
Hongwei Qin c2950fc434 feat(ui): forget orphaned remote workspaces when a profile is deleted (#508)
Deleting an SSH profile used to leave every remote workspace entry that had
connected through it behind, labelled with a bare internal id and retrying a
route that could never work again.

`RemoteRef` now carries a `RouteSnapshot` of the profile it was made from —
name, user, host, port — written at creation and refreshed on every reopen,
`serde(default)` so older session files load. The snapshot serves labels only:
`PartialEq`/`Hash` ignore it, or a refresh would split one entry into two.

Deleting a profile cascade-forgets the entries routing through it. Forgets,
not deletes: `WorkspaceRemove` is never sent, so the sessions on the remote
machine keep running and connecting again under a new profile brings them
back from the machine's own workspace list. An entry holding a live or
in-flight link is left alone, as is one whose window is still on screen — a
window whose workspace the store has forgotten reads as local, and its next
tab would open a local shell on what the user still sees as a remote box.
Whatever survives parks instead: no retries, no error, and an inline action
to drop it deliberately. A live or preempted link outranks a lost route.

Labels fall back from the live profile to the snapshot to a placeholder, so
no branch renders a bare UUID. Resolving the live name reads memory rather
than reparsing `~/.ssh/config`, because that path runs on every frame of a
window with a remote workspace open.

Closes #485.
2026-08-11 22:10:56 +08:00
webdevandl0ng-ai 7bd26390d7 fix(files): replace what a drop lands on only once the copy is whole (#490) (#512)
* fix(files): replace what a drop lands on only once the copy is whole

A drop onto a name already taken destroyed what was there before it knew
it could put anything in its place: `copy_into_dir` removed the
destination and only then started the copy. A copy that fails partway — a
full disk, a control connection that drops mid-tree on a remote workspace
— therefore left the destination holding neither the old thing nor a
whole new one, and the panel reported the failure over a hole where the
file used to be. "Replace" gave up the one guarantee it had: that saying
no to the copy still leaves you what you had.

The copy now lands on a working name beside the destination, what is
there is moved aside rather than removed, and a rename — one metadata
operation, not a tree walk — puts the new copy in its place; the old one
goes only once the new one is there. A failure anywhere puts it back, and
in the one case where even that fails it is still on disk under
`.tty7-replaced-<name>` instead of not at all.

The other half of the same drop is a collision the destination cannot
see. Two sources of one drop can carry the same name — `~/a/notes.md` and
`~/b/notes.md` dragged in together — and neither is a conflict, because
the destination has never heard of either. Both were planned onto the
same path, so the second was written straight over the first while the
panel reported both as copied: a file the drop said it had copied, gone,
with nothing said. One name is one file. The first claim on it stands and
the rest are refused with a reason.

Three tests, two of which fail on the code they replace: a same-name pair
reports one copy and one refusal rather than two copies of a single file,
a replacement whose copy cannot finish leaves the original readable, and
one that does finish leaves no working files behind.

The drop is not in a release yet, so the changelog says this in the entry
for the feature rather than in a Fixed line about a bug nobody met.

Closes #490

* fix(files): stop the pass that carries the answer from asking again

`conflicts` was filled on every pass, including the one that runs after
the user has already answered "Replace". The panel prompts on any
non-empty `conflicts` (`file_tree.rs:1281`), so the dialog re-opened over
a replacement that had already happened — and since that branch is an
`else if`, `report.errors` is never read while `conflicts` is set, so the
same pass silently swallowed every error it produced: the new
`FileDropNameTaken` refusal when a drop both collides with itself and
lands on an existing name, and a replacement that failed — the case the
staging exists to survive.

The field already documented the invariant the code did not keep:
"non-empty only when the caller asked without `overwrite`". Keep it, in
the one place that fills it.

The remaining silence is the narrow one: both renames are a control round
trip each on a remote host, and a link that drops between them leaves
what was there under the name it was moved aside to. That was a
`log::warn!` and nothing else. The panel now names it, so the answer to
"where did my file go" is on screen rather than in a log nobody opens.
`free_name_beside` hands back the bare name for it — the sentence wants a
name, not somebody else's whole remote path.

The added test walks the real sequence: ask, answer, replace. It fails on
the previous revision with `conflicts == ["note.txt"]` after the replace.

---------

Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-11 22:06:39 +08:00