Commit Graph
1062 Commits
Author SHA1 Message Date
l0ng-ai fa5171d015 fix(restart): stop Restart Server taking the window with it
Clicking Restart Server made the whole app disappear, with a double-lease
panic in the crash log: cannot read Tty7App while it is already being
updated.

The work that puts the window back together after the restart ran inside
`update_in` on this window's own entity, and it ends by rebuilding every
local window from the machine tree. The first thing that rebuild asks each
window is which tabs it is showing, which it reads back out of the window
registry — so the first window it reaches for is the one the closure
already holds leased, and gpui answers a double lease by panicking, which
on the main thread is the process.

Split into `settle_after_restart`: the window's own state first, then the
resync outside the lease, then the focus. The resync still runs either way
the restart went, because a refused handoff leaves the daemon serving the
panes this window already dropped (#554).
2026-08-16 17:28:00 +08:00
l0ng-ai f335a53f61 fix(restore): keep a restored screen out of ConPTY's viewport
On Windows a restored pane came back with its shell drawing in the wrong
place: the prompt stopped responding where it stood and the restored text
filled with fragments of whatever was being typed.

A ConPTY does not hand the terminal a stream, it hands it a rendering of a
screen buffer conhost owns, addressed absolutely and counted from that
buffer's top-left, which starts blank with the cursor at (0,0). PSReadLine
redraws the line being typed as `ESC[6;20H ... ESC[6;26H` on every
keystroke, and conhost frames what it paints the same way. Those row
numbers are only right if the client's viewport is conhost's buffer, row
for row.

Restored output is output conhost never produced and knows nothing about.
Left on screen it shifts every row conhost names, so the first repaint of
the input line lands on the old text. Nothing the client can do fixes it
afterwards: the offset is not constant, and it would have to be unpicked
from every absolute address in the stream.

So the restore preamble now ends by scrolling the restored screen out of
the way. `ESC[2J` on the primary screen scrolls the viewport into history
rather than erasing it, so the screen the daemon restored is one scroll up
rather than gone, and `ESC[H` leaves the cursor where a fresh ConPTY
expects to find it. Unix keeps the old behaviour: a shell there positions
itself relatively, so the restored screen can stay where it can be seen.
2026-08-16 17:27:27 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

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

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

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

Two things in the machine tree had to follow:

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

Closes #621

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 14:24:22 +08:00
l0ng-ai 77590b5c70 docs: bring back a single platforms badge, in blue 2026-08-15 14:02:42 +08:00
l0ng-ai 7aacbdca7e docs: fold platform support into the subtitle, point the version badge at releases 2026-08-15 13:50:53 +08:00
l0ng-ai b884aa36c5 docs: restore per-OS platform badges with a flat Windows mark 2026-08-15 12:12:52 +08:00
l0ng-ai 8c875d8be6 docs: swap the OS logos for a single shell-prompt platform badge 2026-08-15 12:07:28 +08:00
l0ng-ai 9673c1ab0b docs: inline the Windows logo, Simple Icons no longer ships one 2026-08-15 11:58:03 +08:00
l0ng-ai a59d6ec28d docs: give the platform badges their own row with per-OS logos 2026-08-15 11:30:15 +08:00
l0ng-ai 6031570798 feat(new-tab): reach saved SSH hosts from the New Tab button (#647)
Adds a saved-SSH-hosts section to the New Tab menu on both the tab strip and the sidebar, ordered by frecency, with a row that opens the full host palette. Holding the modifier opens the host in a split instead of a tab.

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

Replaces the raw target string with the resolved machine label on the reconnect banner and on the connecting pane, so a profile-backed machine no longer shows a bare config UUID in "Connecting to …" or "Could not reach …".
2026-08-15 11:11:26 +08:00
l0ng-ai f1144deb9f fix(ui): restyle the in-app notification to sit in tty7's own visual language (#646)
Bumps the gpui-component pin to pick up the notification restyle: flow-positioned status icon and close button that centre on the first line at any wrap count, a hairline-shadow surface in light theme, and a type ranking expressed in rems so it survives the ui_font_size setting.
2026-08-15 11:03:13 +08:00
ARNOandl0ng-ai 2e6103cf19 Retire to the tray on window close; cold start no longer stalls on stale daemon files (#639)
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 23:04:15 +08:00
ARNO 4f1e181bdf feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks (#637)
* feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks

feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks
Nushell was the only detected shell with no integration: shell_kind
never matched `nu`, so panes were spawned with no OSC 7 cwd reports
and the daemon could not follow `cd` — pwsh, zsh, bash and fish all
do. Recent Nushells emit their own OSC 133 marks and a Windows
`OSC 9;9` cwd protocol, but tty7 only consumes OSC 7, and the native
`shell_integration.osc7` toggle defaults to off.
The injection rides `nu --config` (Nushell has no ZDOTDIR analogue): a
throwaway config.nu sources the user's own config.nu back in first,
then appends hooks. `source` is a parse-time construct in Nushell — it
cannot be guarded at runtime or name a missing file — so the Rust side
resolves the path with the same rules `$nu.default-config-dir` uses
(APPDATA / XDG / HOME) and substitutes a literal, or a no-op line when
there is no config.nu. The hooks report cwd (OSC 7, %-escaped, `/C:/…`
shape on Windows), prompt start (A) and the previous command's exit
(D, gated on a flag the pre_execution hook arms so the first prompt
emits nothing), mark command output (C), and wrap prompt_indicator for
the prompt-end mark (B) only when the config defines one — recent
Nushells' built-in prompt keeps drawing its own indicator and B mark,
and overlapping marks merge in the daemon's prompt-state machine.
Remote SSH and WSL panes are unchanged: their bootstraps cannot carry
a Nushell config, and a nu-as-login-shell session would break on one.
Tests: static assertions on the script, setup dispatch, literal
substitution, and a Windows real-PTY cycle asserting OSC 7 follows
`cd` and every mark arrives with the correct exit status.

fmt

* fix(shell): resolve the Nushell config dir the way nu does

The wrapper's `--config` replaces the user's config.nu entirely, so the
path it sources back must be the one nu itself would load — anything
else silently strips macOS panes of their prompt, aliases and
keybindings. `dirs::config_dir()` is `~/Library/Application Support`
on macOS, not `~/.config`, and nu-path consults `$XDG_CONFIG_HOME` on
every platform (Windows included) but only when it is non-empty and
absolute. The resolution now mirrors that: a per-platform default plus
XDG winning only in the exact shape nu accepts.
Also gate the OSC 7 backslash translation on Windows
(`$nu.os-info.name`) so a Unix path with a literal backslash survives,
and harden the real-PTY tests: one line per submitted command
(reedline drops input while a command runs) and a grace period after
the D mark so the cwd report that follows it in the same prompt cycle
lands in the transcript.
2026-08-14 22:32:47 +08:00
l0ng-ai 5c284799aa fix(tabs): stop a command that is over in a blink from flashing across the tab
The tab title follows the terminal's OSC title, and nearly every prompt
framework sets that to the command it is about to run and puts the old
title back at the next prompt. For anything that finishes in a blink both
edges arrive within a few frames, so the label showed the command and
snapped straight back — a flicker that reads as a rendering glitch rather
than as information.

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

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

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

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

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

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

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

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

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

Three things the no-/proc fix left standing.

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

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

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

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

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

---------

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

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:57:24 +08:00
l0ng-aiandl0ng-ai 52b823a9f3 test(history): stop the sweep test deleting the panes of tests running beside it (#638)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:56:44 +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
webdev 0346e35b40 fix(shell): stop injecting into a zsh or fish the user gave arguments to (#629)
The zsh and fish arms of `shell_integration::setup` never checked `has_custom_args`, so a shell the user launched with their own arguments was injected anyway — fish had `-C <script>` appended to its argv, zsh had its ZDOTDIR swapped. Both arms now sit behind the same gate bash, PowerShell and WSL already used, hoisted to a single early return ahead of the dispatch so a new ShellKind cannot silently reintroduce the bug.

Docs now describe what the code does: the `shell` row's own `{"program": "fish", "args": ["-l"]}` example loses integration under this rule, and the shell-integration note distinguishes user-written arguments from the ones detection supplies (Git Bash, WSL).

Part of #624; the native-input-mode half is separate.
2026-08-14 15:57:20 +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 60ef3fb758 fix(shell): name the user, host and home in a Unix pwsh pane's title (#611)
The PowerShell integration read its identity from USERNAME, COMPUTERNAME and
USERPROFILE. Those three spellings exist only on Windows; on macOS and Linux
they come back empty, so every pwsh pane there was titled `@:` followed by a
full un-abbreviated path, and the `~` shortening never fired at all. Read them
through .NET instead, and take home from PowerShell's own $HOME, which is
correct everywhere.

Nothing gated this to Windows and nothing tested it off Windows either: every
pty round-trip in this module was `#[cfg(windows)]`, so a Windows-first script
shipped to two platforms it had never run on. Open the harness up to Unix and
give pwsh its own round-trip there.

That took two fixes to the harness. It typed at spawn time, which puts the
keystrokes ahead of the cursor-position reply in the same input stream — pwsh,
still waiting on that reply, eats `false\r` as the answer to its own query and
the command never runs; wait for the prompt-end mark before typing. And nothing
was answering `CSI 6n`, which PSReadLine blocks on before it will draw anything.
Enter comes in as a parameter now, because a raw-mode reader only accepts `\r`
where a line-discipline shell also takes `\n`.

While rewriting the home match: require the separator. With a home of
`/Users/ann` a bare StartsWith also swallowed `/Users/annex`, retitling it
`~ex`.

Found while investigating #583, which reports a pwsh pane freezing on `ls`.
This is not that bug — the injection runs clean on macOS pwsh 7.6.4 both over a
raw pty and under tmux — but it is a real defect on the same untested path.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:14:49 +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