Commit Graph
1010 Commits
Author SHA1 Message Date
l0ng-ai 82935f77cf fix(tree-sync): stop an abandoned Replace debt deleting the tabs it was pulling (#579)
`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.
2026-08-13 11:46:02 +08:00
Hongwei Qinandl0ng-ai 664b766698 fix(settings): split Shell Arguments like a shell, quote them on the way back (#551) (#573)
* fix(settings): split Shell Arguments like a shell, quote them on the way back (#551)

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:38:49 +08:00
Hongwei Qinandl0ng-ai cb473daf27 fix(cli): refuse a broken send address instead of typing it into your pane (#538) (#567)
* fix(cli): refuse a broken send address instead of typing it into your pane (#538)

A lone positional that starts with `%` but fails parse_pane (`%3x`) used
to fall through to the text branch: the typo was typed into the caller's
own pane and any --key followed it there, so one wrong character
redirected an interrupt to whatever the caller was looking at. The guard
now propagates the parse error when the `%` is followed by a digit —
"clearly tried to write an address" — and leaves `%`-led text whose
second character is not a digit (`%s/foo/bar/`, `%!sort`) on the text
path it always was, per the review's narrowing.

The explicit address slot also accepts bare ids now: `pane ls --json`
prints `83`, not `%83`, and refusing the bare form made the workaround
for the typo hole (`"%${TTY7_PANE#%}"`) uglier than the hole. This
matches what pane_from_env already accepted and closes the missing-`%`
variant of the same mistake.

Tests cover the branch with a `Context { pane: Some("5") }` — every
existing send test used `Context::default()`, where the fallback errors
OUTSIDE_SHELL before the guard is reachable, which is why the hole had
no test.

Also correct the `ws rm` docs (#539): the reference claimed its panes
become orphans found via `pane ls --all`, but the code has hung them up
since #319; only a hang-up failure (reported by pane id) leaves
orphans. The site reference, the skill reference, and `ws rm --help`
now say so.

* fix(cli): keep the send guard to what actually looks like an address

The narrowing was described more widely than it works: a digit-led token
that fails to parse (`3x`) still types, only `%` then a digit refuses, so
the reference and the skill both promised an error that never comes. Say
what the code does and point at the two-argument form as the way to type
an address-shaped string anyway.

Now that the `%` is optional, `parse_pane` also has to be stricter than
`u64::from_str`, which accepts a leading `+`: a bare `+5` meant as text
would otherwise address pane 5. An address is digits and nothing else,
and `pane_from_env` delegates rather than repeating the read.

The broken-address arm parsed twice and ended in an `unreachable!` that
a future edit could walk into; one match on the parse result carries the
error out directly. A lone bare id is the one behaviour this takes away,
so it says how to type the number instead, and a test pins that it never
quietly presses a key at the pane the id names.

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

Review follow-ups on the file-link work.

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

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

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

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

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

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

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

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

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

---------

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

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

The sidebar surfaces were never affected — they size their slots to what they draw, so every slot they allocate gets measured.
v26.8.3
2026-08-12 19:46:10 +08:00
l0ng-ai 71c6783fb4 chore(release): v26.8.3 2026-08-12 18:41:37 +08:00
l0ng-ai 473c94ecba docs(skill): show how to update an installed tty7 skill
`skills add` does not refresh a skill that is already installed, so the
one install line left existing users with no documented way forward.
Add the `skills update tty7` counterpart to both READMEs and give the
agent-skill page a short Updating section covering update and remove.
2026-08-12 17:09:55 +08:00
ARNO 6f6eee7eda fix(switcher): give a machine name its own line above the address (#560)
A machine group header in the workspace switcher drew the machine name and
its endpoint (`user@host:port`) on one row. The endpoint was a fixed-width
flex item — up to `TAB_PATH_W`, never shrinking — so it claimed its space
first and the name, `flex_1` off a zero basis, collapsed into whatever was
left. A machine whose name runs long read as a stub: `y...`, `GAEM...`.
The "other machines" rows paired label and address the same way and
squeezed the same way.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 11:41:34 +08:00
webdev 4da3868797 feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443
2026-08-12 10:52:35 +08:00
webdev bc7141404e fix(wsl): stop handing bash an rcfile the distro cannot read (#535)
The WSL bootstrap writes the rcfile on the Windows side and names it to
the distro over `/mnt`. That path is not a given — automount can be off,
`/etc/wsl.conf` can move the root, and a distro can have no drvfs at all
— and the bash arm passed it to `--rcfile` without asking whether it was
there.

bash ignores an unreadable `--rcfile` in silence. And because tty7 starts
it non-login on purpose, so that `--rcfile` is honoured at all, there is
no `.bash_profile` pass to fall back on: such a pane opened a shell that
had read no startup file of any kind. Not tty7's integration, which is
the part that was supposed to be optional — the user's own `.bashrc`,
gone, with nothing said. The pane looks fine; every alias, prompt and
function the user wrote is simply absent.

The arm now checks the file is readable from inside the distro before
committing to it, and falls back to a plain login shell when it is not:
no integration, but a shell that reads everything the user wrote. That is
the same shape the zsh arm beside it already uses, and the failure worth
having of the two.

The dispatch test grows the case: with a `TTY7_RC` naming a file that is
not there, the bootstrap must exec `bash -l` and must not mention
`--rcfile` at all.
2026-08-12 10:45:33 +08:00
webdev 06d4d7c301 feat(wsl): give a zsh distro the same shell integration a bash one gets (#533)
Closes #135
2026-08-12 10:34:15 +08:00
l0ng-aiandl0ng-ai b7196ae49a Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Fixes #527

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

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

Closes #524. Closes #525.
2026-08-11 22:53:29 +08:00
cwatanab 5452414a20 feat(agent-hooks): report the opencode session id (#481)
The opencode plugin now captures the session id from event properties and
forwards it to the emitter on stdin, so a restarted pane can resume the same
session with `opencode --session <id>`. Also maps session.status busy/idle
for versions that no longer emit session.idle.

A task-tool subagent runs in a child session whose events are structurally
identical to the pane's own, so the bridge remembers child ids from
session.created/session.updated and lets their events pass without touching
the pane's session — otherwise the pane would report (and resume) the
subagent's session, and a subagent going idle would call the pane done.
2026-08-11 22:52:13 +08:00
l0ng-aiandl0ng-ai ba6e4432ab fix(files): follow a coding agent into its git worktree (#526)
The Files tree rooted itself at the foreground process's cwd, read out of
the kernel (`proc_pidinfo` on macOS, `/proc/<pid>/cwd` on Linux). That is
the right answer for a shell and the wrong one for a coding agent: moving
into a git worktree does not `chdir`, so `claude` that entered
`.claude/worktrees/feature` still reports the directory it was launched in,
and the tree stayed rooted in the main checkout for the rest of the session.

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

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

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

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

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

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

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

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

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

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

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

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

Closes #490

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-11 22:06:39 +08:00
l0ng-aiandl0ng-ai 191a058e4c docs(changelog): record the silent-failure batch (#522)
Eleven entries for the eighteen issues of the #486-#504 audit that landed
today, one per change as it shipped. They were left out of their own PRs on
purpose: seven branches editing the same `### Fixed` block would have spent
more time resolving each other than writing the entries.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:54:36 +08:00
l0ng-ai 657bb4e0b7 chore(assets): crop the hero screenshot tighter around the window 2026-08-11 21:54:19 +08:00
l0ng-aiandl0ng-ai ce4a19ae89 fix(remote): let the supervisor's view of a link be the only one (#517)
* feat(i18n): give the strip and the switcher the words for a link that is retrying

The strip could count reconnect attempts but never say what any of them ran
into, and the switcher had no word at all for a machine between links or for
a workspace another client is holding.

* fix(remote): let the supervisor's view of a link be the only one

Four bugs met in this file's status pipeline, all of them a second opinion
about a link that the supervisor already knew better.

Take Back said it worked and did nothing (#488). Preempting a GUI client does
not drop its control link — the server only hangs up on a `dedicated` one — so
the pump's live branch marked the machine Attached and moved on, while the only
site that ever sent `WorkspaceAttach` sat in the *reconnect* path, which a live
link never reaches. The strip read Attached, `accepts_input` agreed, and every
keystroke went into a socket the far end had handed to somebody else.

So the live branch now attaches too. `reclaims_due` collects the workspaces the
far end has not been told about — the ones a Take Back is reclaiming, and any
this client has never spoken for over the link that is up now, because
`connect_blocking` brings a link up and stops there and every switcher-initiated
connect used to leave the daemon holding nothing. `attaching` keeps a reclaim
that is on the wire from being sent again four times a second, `attach_sent`
scopes "already told them" to one link, and `finish_reclaim` puts the takeover
back — with the name that came with it, which is why `reclaiming` now carries it
— when the far end refuses. While a reclaim is in flight the workspace reports
Connecting: not Attached, because it is not, and not Preempted, because the
button that would say so must not be clickable twice.

A window's own failed connect no longer outranks the supervisor (#489). It was
returned before `RemoteLinks` was consulted at all, and never compared against
the window's own machine, so a failed connect to the GPU box replaced the strip
of a window sitting happily on the build box. `resolve_status` takes both, and
`clear_window_failures_for` retires what the windows still say once the
supervisor gets through — precedence alone would not do, because the switcher
reads `ConnectFlow` directly.

And a reconnect says why (#498). `launch_attempt` set `Failed(e)` and the next
tick overwrote it back to `Reconnecting`, 250ms later, so the reason existed for
a quarter of a second. `MachineLink::last_error` outlives that. Retrying for
ever stays: this is a laptop lid closed overnight, and giving up would be worse.

* fix(switcher): draw a machine from what the supervisor knows, not from a table entry

`link_state` read this window's `connect` and then asked whether a `HostLinks`
entry existed. The pump drops that entry the moment a link dies, so a machine
being retried right now rendered identically to one nobody has ever connected
to — same grey dot, same "not connected", and, because the group body is only
expanded when the link is not Offline, its workspaces vanished from the panel
for the whole reconnect (#497).

`Link::Reconnecting` is the state that was missing, and `link_from` builds it
from `RemoteLinks::machine_status`, so the panel and the strip now read the same
source. Disconnect and the machine menu treat it as engaged: a retry in flight
is exactly what Disconnect is for.

The route `launch_attempt` could not build wrote `LinkState::Failed(e)` where
nothing the switcher read would ever find it. The group's error line now falls
back to the supervisor's failure and to `last_error`, rather than having the
supervisor write into `remote_host_errors` as well — one source, not two.

A takeover leaves the link alone, so a machine whose workspace another client is
holding drew as plain Connected with an "Open" badge on the row. It says so now,
on the header and on the row it happened to.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:42:37 +08:00
l0ng-aiandl0ng-ai bf9c57dec7 fix(ssh): let a rejected stored credential ask again (#519)
* fix(ssh): let a rejected stored passphrase ask again (#486)

Saving the wrong passphrase for an encrypted key locked that key out
permanently. `passphrase_submit` wrote `SetKeyPassphrase` on the
"remember" checkbox alone — before the daemon had tried the secret, since
`apply_keychain_write` runs ahead of `respond_active` — and
`try_identity_file` treated a stored passphrase as final: a decrypt
failure with one went straight to "could not decrypt identity file", with
no prompt and nothing in the UI that could let go of it.

The daemon now says so. `AuthPromptKind::KeyPassphrase` grows a
`rejected` flag, and a stored passphrase that does not open the file
falls through to the interactive prompt carrying it, so the typed answer
still gets its attempt. A passphrase the user typed this time keeps the
hard failure — that is a wrong answer, not stale state. The sheet renders
the warning line the password sheet already had, and a rejected prompt
answered without "remember" now emits `DeleteKeyPassphrase`, mirroring
the password idiom exactly.

The flag is a `#[serde(default)]` field on a struct variant of an
externally tagged enum, which is compatible in both directions: an older
peer never sets it and serde ignores fields it does not know. So
`PROTOCOL_VERSION` deliberately does not move — the remote-server
handshake gates on it, and a bump would turn away older servers over a
field they can safely ignore. `protocol.rs`'s compat test pins both
directions.

Also: deleting an SSH profile now drops the key-passphrase entries no
other profile still references, which is what `delete_profile_confirmed`'s
own comment already claimed to do but only ever did for the password.

* fix(ssh): stop replaying a stale password at keyboard-interactive (#487)

`try_keyboard_interactive` answered a password-shaped round from the
keychain, marked the stored password spent whether or not it had been
used, and returned on the first `Failure` — so the `MAX_ROUNDS` loop
never got a second pass with the stored password withheld. The same dead
secret went out on every reconnect and the user was never once asked to
type a different one; `ki_submit` always emitted `KeychainWrite::None`,
so nothing could clear it either.

`collect_ki_answers` now reports where its answers came from, and only a
round that actually sent the stored password spends it — which also fixes
an OTP-then-password flow that was refusing the stored password for no
reason, its first round having burned the allowance on a code. On a
rejection whose last round came from the keychain, and where the server
still offers the method, the request is started over with the stored
password withheld, so the next round reaches the prompt. That retry is
bounded twice over: the restart spends the stored password, so no second
restart can qualify, and the round counter it shares with the
info-request loop caps the method either way. The failure text now says
which of the two was turned down.

Scope, honestly: the only live scenario is auth mode Auto against a
server offering keyboard-interactive but not password, with a stored
password for that endpoint — a profile pinned to KeyboardInteractive gets
`password: None` and always prompts, and Password never tries KI. Whether
the symptom shows also depends on the server: OpenSSH ends a rejected
kbdint request with USERAUTH_FAILURE (symptom holds), while a device that
re-issues an InfoRequest in the same request already reached the prompt.

`AuthPromptKind::KeyboardInteractive` grows a `#[serde(default)]`
`stored_rejected`, same both-directions compatibility as `KeyPassphrase`'s
`rejected` and the same reason `PROTOCOL_VERSION` stays put. The sheet
shows the warning line and, on submit, forgets the rejected password.

That needed an endpoint the KI prompt does not carry, which also fixed a
bug next door: `raise_routed_auth` called `from_prompt(.., None, false)`,
so every routed password write was keyed to port 22 regardless of the real
port and the rejected self-heal could never fire there. `PendingAuth` now
carries the endpoint and the auto-supplied flag, read straight off the
route's `NativeSshSpec`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:40:49 +08:00
l0ng-ai d4643f0532 fix(ssh): offer keys in the order the user asked for them (#520)
Every public key offered spends one of the server's `MaxAuthTries` — six
by default — whether or not the server wants it, so the order decides who
gets locked out when the budget runs dry. Offering the `~/.ssh` defaults
before the agent, and offering them on top of a profile's own key rather
than instead of it, spent the budget on the keys least likely to be
accepted: three stale defaults in `~/.ssh` with the working key in a
loaded agent could exhaust the attempts before the agent was reached, and
a profile naming its own key came off worse than one naming none.

Order the sources by how plainly the user asked for the key:

  1. a key the profile names   — "use this one"
  2. the agent                 — "I loaded these"
  3. the ~/.ssh defaults       — nobody said anything, we are guessing

Steps 1 and 3 are the same leg, because the defaults now stand in for the
identity list only when the profile names no key of its own, the way
`IdentityFile`'s default works in ssh_config — verified against the local
OpenSSH: `ssh -G` lists five defaults with no IdentityFile and only the
named key with one. WezTerm and Tabby both replace rather than append too
(wezterm-ssh/src/config.rs:589, tabby-ssh/src/session/ssh.ts:194).

The file-vs-agent order has no single convention to follow — WezTerm asks
the agent first, Tabby reads files first, OpenSSH merges the two and
trims the agent's extras with `IdentitiesOnly`, which tty7 does not parse.
This order agrees with OpenSSH and Tabby when the user named a key and
with WezTerm when they did not.

Dedup by canonical path goes with it: the two lists are alternatives now,
so there is nothing to dedup between them. `auth_steps` and
`identity_offers` carry the two rules as pure functions, so both are unit
tested instead of living inside the async round. The GUI's keychain
preload follows the same rule, so both sides still key passphrases by the
same strings.

Closes #513.
2026-08-11 21:23:17 +08:00
l0ng-aiandl0ng-ai 16ef93693f fix(settings): confirm before forgetting a password other profiles share (#510)
"Forget Password" was a bare menu item: one click deleted the keychain
entry, with no confirmation and nothing said about who else it took
down. The entry is keyed by `user@host:port`, so two profiles that reach
the same endpoint — one direct, one through a jump host — share exactly
one secret, and forgetting from either row signed both of them out. The
notification even worded itself by endpoint while the action hung off a
single profile's menu.

It now asks first, the way deleting a profile does, and when the
endpoint is shared the dialog names the blast radius instead of leaving
it to turn up at the next connect on a host nobody touched.

Deleting a profile stays conservative on purpose — the menu that could
remove the secret is about to disappear — so the two paths keep their
different policies. What they no longer keep is two copies of the
"is this endpoint shared" question: `profiles_sharing_endpoint` is now
the one place that answers it, and it has the test.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:52 +08:00
l0ng-aiandl0ng-ai 01969ef6bb fix(settings): say what an ssh config import added, updated and could not keep (#515)
Importing from ~/.ssh/config was silent in three ways. A missing or
unreadable file did nothing; a file of nothing but `Host *` and `Match`
did nothing; and a successful import did nothing visible either, so the
only way to learn what had happened was to go count the host list.

Options tty7 has no field for — IdentityAgent, CertificateFile,
AddKeysToAgent and the rest — were dropped without a word. They still
are, because there is nowhere to put them, but the import now names
them and the hosts that set them instead of pretending they were kept.

Parsing keeps each keyword's original spelling alongside the lowercased
form it matches on, and `option_is_supported` is the one list both the
resolver and the report read, so the two cannot drift. Ignored options
are grouped per Host block rather than per resolved alias: a keyword
under a two-alias `Host` line is one omission, not two, and `Host *`
noise stays out of the report entirely.

`merge_imported` now returns added/updated/unchanged, comparing the six
fields it writes before it writes them — so re-importing an unedited
file reports six hosts unchanged rather than six updated.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:36 +08:00
l0ng-aiandl0ng-ai 9c9a18f410 fix(settings): refuse a half-filled SSH profile instead of saving it (#511)
The SSH profile form saved whatever was in it. An empty host wrote a
profile that renders as a blank row in the host list and hands
`TcpStream::connect` an empty name — and Connect had no gate at all, so
the first thing the user heard about it was a DNS error naming nothing.
A jump host with a typo in it resolved to `None` and saved as a direct
connection, with the field blank the next time the form opened. And
`parse_host_port` was `parse().unwrap_or(0)`, so `proxy.example.com`,
`proxy.example.com:` and `proxy.example.com:88O` all saved a proxy on
port 0, which the socket layer got to explain (#492, #493, #494).

The rules now live in `validate_ssh_draft`, a plain function over a
plain-String snapshot of the form, which returns both the profile the
form would save and what is wrong with it. Both, always: the Escape
prompt asks whether the form differs from the config, and handing back
only the errors would make a brand-new invalid profile compare equal to
the nothing on disk — Escape would throw the typing away without asking.

Only the host is required. A name is not, because the list already falls
back to the address and every host imported from ~/.ssh/config arrives
without one. A blank port still means 22, but a non-empty one has to be
a port, so "0", "abc" and "70000" are refused rather than saved as
written or quietly rewritten. A proxy address with no port takes the
scheme's default (1080 / 8080) and `host_port_text` writes that back
into the field, so the number it picked is visible; a colon with nothing
usable after it is an error. `map_proxy` also stops treating port 0 as a
proxy, because configs written before this are already on disk.

Each complaint prints under the field it is about, and Save and Connect
are disabled while any of them stands; a section holding one unfolds so
the disabled button always has a visible reason. The "needs a host" line
waits until the name/host/port/user group has something in it — every
field notifies per keystroke, so otherwise a new host would be told off
before anyone had typed a character. Consequence: on a pristine new form
Save is now disabled where it used to be enabled.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:19 +08:00
l0ng-aiandl0ng-ai efe345174b fix(ssh): stop a new host-key algorithm from reading as a compromise (#516)
* fix(ssh): stop a new host-key algorithm from reading as a compromise

A host that grows an ed25519 key beside the ssh-rsa one it has always had
raised the full man-in-the-middle sheet — red border, fingerprint diff, a
"type yes" field — because `check_in_str` folded "known by another
algorithm" into `HostKeyStatus::Changed`. OpenSSH treats a key of an
algorithm the host has no entry for as simply unknown, and saves the alarm
for a key that contradicts one on file.

`ChangedAlgorithm` splits the two apart, with `Changed` keeping precedence
so a same-algorithm mismatch still screams however many other-algorithm
lines sit beside it.

The dialog was only half of it. Negotiation started from russh's default
order, which leads with ed25519, so a host known only by ssh-rsa was
*asked about on every single connection* — and an attacker could pick an
algorithm the user had no entry for to trade the alarm for the mild
confirmation. `build_preferred` now orders the host-key list the way
OpenSSH's `order_hostkeyalgs()` does: what is already on file goes first,
nothing is dropped, and a pinned `HostKeyAlgorithms` is left alone. It
matches on key type, so all three RSA spellings travel together rather
than pinning the host to SHA-1 signatures.

The prompt reuses `AuthPromptKind::HostKeyUnknown` with an added optional
field rather than gaining a variant: the enum is externally tagged and
crosses both the daemon/GUI and the GUI/tty7-server boundaries, where a
new variant is a hard decode failure on an older peer and a new field is
not.

Also fixes a defect the issue did not mention: overriding a genuinely
changed key appended the new line without removing the old one, and since
any same-algorithm match answers `Known`, the superseded — possibly
attacker's — key stayed trusted forever, silently. The superseded line is
now dropped first, and only lines naming this one host are touched, so a
wildcard or `@revoked` entry is never collateral.

* fix(ssh): make the Override button on a changed host key actually override

`host_key_changed_decision` returns `accept: false` for anything but
"yes", which is byte-for-byte what Abort sends — and the button had no
disabled state and closed the sheet unconditionally. So clicking Override
with an empty field rejected the key and dismissed the prompt, indistinguishable
from having aborted, with nothing said. Enter on the input had the same trap.

Override is now dead until the word is there, which is what the line above
the field has been claiming all along, and Enter on a half-typed answer
leaves the sheet up instead of quietly deciding. `changed_confirmed` is the
single predicate behind both, so the button and the decision cannot
disagree about what "yes" means. `host_key_changed_decision`'s `false`
branch stays as defence in depth.

Both input subscriptions also notify on `Change`, or the enabled flag would
go stale between keystrokes, and a hint appears once the field holds
something that is not "yes". Abort is untouched: still primary, still last.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:03 +08:00
l0ng-aiandl0ng-ai c91f0cf660 fix(forwards): say why a forward was refused, and never lose the rule it replaces (#514)
The managed-forward form in the Forwards panel used to bare-`return` from Add
whenever the fields did not make a rule — an unparseable bind port, a target
port of 0, an empty target host for a Local or Remote rule. The button did
nothing, said nothing, and left the form exactly as it was. It now collects
the fields through the same conditions the settings sheet applies
(`ForwardRuleForm::collect`), keeps Add disabled while there is nothing to
add, and shows the settings sheet's own "needs both" / "needs a listen port"
line under the form once it has been touched.

Saving an edit removed the old forward and then added the new one, so a rule
that could not be started took a working forward with it. Remove-then-add is
still the order — the ordinary edit keeps the bind port, and adding first
would collide with the very rule being replaced — but the panel now looks at
the entry the add appended, and on `ForwardStatus::Error` it takes that entry
back out, puts the old rule back, and keeps the form open with the reason
under it. `mf_editing` carries the whole `ManagedForward` rather than an id so
there is something to put back, and it is re-pointed at the restored entry,
which comes back under a new id.

Adjacent, and the reason a failed request could not be told from a successful
one: `ForwardRoute::add`/`remove` and their `RemoteTerminal` fallbacks turned
every transport error into an empty `Vec`, which the caller then assigned
straight into the panel's list — so one unreachable round trip blanked a panel
full of live forwards. They return `Option` now, and the panel only takes a
list the far side actually sent.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:21:47 +08:00
l0ng-aiandl0ng-ai f2fe829cb6 fix(sftp,forwards,files): tell a failure apart from an empty result (#518)
* fix(sftp): stop a failed poll from reading as an empty transfer list

A transfer poll that could not reach the daemon answered with an empty
`Vec`, which is indistinguishable from "every transfer is gone": the tray
disappeared and every upload the panel was waiting on counted as landed,
so a spurious "the upload finished" refresh fired. Over a link that is
down that is the permanent answer, not a blink.

`SftpRoute::transfer_list` and `RemoteTerminal::sftp_transfer_list` now
report the failure the way `sftp_list` already does. A failed poll keeps
the jobs the panel last saw, settles nothing, and says so in the transfer
tray through a new `jobs_error` — kept apart from `SftpPanelState::error`,
which blanks the directory listing a poll knows nothing about.

* fix(forwards): let a forward whose loop has exited say so

`ForwardEntry.status` was written once when the forward was set up and
never touched again, so a local or dynamic forward went on reporting
`Listening` after its accept loop had already exited. The pane outlives a
dead SSH transport on purpose, the daemon keeps it while a subscriber is
attached, and the panel re-polls every 2s — so the stale `Listening` is
not a blink but the permanent answer. `nc` to the port gets accepted once
and refused thereafter while the panel still shows it as live.

The status is now an `Arc<Mutex<ForwardStatus>>` shared with the task, and
both break arms record why they left: the listening socket closed, or the
SSH connection went away. `ForwardStatus::Error` carries it rather than a
new variant, because the enum crosses the protocol to `tty7-server` builds
that would not know one. `find_auto_local` reads the live status too, so a
loopback link is no longer reused after its forward has stopped serving.

A remote forward has no accept loop of its own — the far end opens the
channels — so it keeps whatever the `tcpip-forward` request answered.

* fix(files): tell a failed search from an empty one, and name the file a write failed on

Two ways the file tree answered a failure with something that reads as a
result.

A search was `unwrap_or_default()`ed inside the worker, so a host that
refused the walk left `hits` empty and the column printed "Nothing matches
{query}" — byte-identical to a genuine zero-hit search. The worker now
reports `(ok, hits)` the way `spawn_load` already reports a listing, and a
failed walk draws a `SearchFailed` note in the danger colour, the same
distinction `FileTreeState.unreadable` draws for a directory.

Creating and renaming pushed the bare `io::Error`, so the toast was
literally "Permission denied (os error 13)" — neither which file nor what
was being done to it. Both now go through `HostOps::notify_err` like
delete and drop-copy already do, naming the file; a rename names the name
it is leaving, which is the one still on screen.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:20:39 +08:00
l0ng-aiandl0ng-ai 27880c0f14 feat(cli): wait on commands, press keys, and reap orphan panes (#505)
* feat(cli): wait on commands, press keys, and reap orphan panes

`tty7 wait` was the orchestration primitive for agents only. A pane with
nothing reporting agent status read as `idle`, so `--until idle` returned
success instantly about a shell that was midway through a build, and there
was no state that meant "the command finished" at all.

Panes with no agent now report `no-agent`, and `free` ends the wait when the
foreground command has exited — the process-tree question `procs` could
already answer but nothing could block on. `send --key` covers the keystrokes
text cannot express, which is what a worker stopped at `waiting` is usually
asking for. `pane close` takes several panes and `--orphans` clears what an
interrupted `run` leaves behind. `doctor` finally performs the hooks check
its own help has advertised.

The skill shipped in this repo predated `wait` entirely and taught a
hand-rolled `procs` polling loop with no notion of delegation; it now covers
the loop, and its agent statuses, `ws rm` orphan claim and not-implemented
list are corrected against the code.

* fix(cli): close the gaps review found in wait, --key and pane close

Five things the first pass got wrong, in the order they bite.

`--until free --changed` waited on a command it had already missed: the
"something ran" edge is only set by a poll that catches the pane busy, and a
command that starts and finishes inside one 500ms interval never is. That is
indistinguishable from a command that never ran, so the timeout now names both
doors instead of letting a finished build read as a hang.

`free` also outranked the agent ladder, which is backwards. A pane whose depth-0
process *is* the agent — the tree cannot tell that apart from a shell at its
prompt — reads free for its whole turn, so a `waiting` the caller explicitly
asked for could be overwritten by a process-tree fact and then withheld by the
`--changed` rule that comes with it. `free` is now consulted only when none of
the requested agent states answered, which is both cheaper and what the docs
already claimed. An empty process tree is "we could not see in" rather than
"free" for the same reason `no-agent` exists.

`--key M-X` sent `ESC x`: the whole spelling was folded to lowercase, which is
free for Ctrl (the C0 rule clears the case anyway) and wrong for Alt, where the
character rides through as itself.

`send --help` listed the key vocabulary by hand next to the table it is a list
of; it had already drifted by one alias. It is generated now.

And a `pane close` batch that could not close everything raised an error, which
left `--json` holding prose exactly when a cleanup script needs to know which
panes are still its problem. It exits 1 with `{"closed":[…],"failed":[…]}`, with
the complaint still on stderr so `-q` reports it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 20:38:27 +08:00
Hongwei Qin 364e24af87 feat(ssh): probe ~/.ssh default identity keys (#507)
A connection with no explicit IdentityFile used to offer the server
nothing at all when the agent was unavailable — the default on Windows,
where the OpenSSH Authentication Agent service ships disabled — and then
reported "no public key was accepted", for keys it had never sent.

Offer the `~/.ssh` defaults (id_ed25519, id_ecdsa, id_rsa) after the
explicit identities and before the agent, from one shared candidate list
in `core::ssh_profile` so the GUI and the daemon key `key_passphrases`
by the same strings. Candidates are deduped against the explicit list by
canonical path, comparing the expanded paths the reader actually opens.
A discovered key that is encrypted is used only when its passphrase is
already cached, never prompted for; explicit keys keep prompting. Files
that do not exist are skipped in silence, a `.pub` is never offered as a
private key, and the failure text now separates "the server turned these
down" from "nothing usable was found".

The tests build their ed25519 fixture at run time from a fixed seed
rather than embedding a PEM blob, so the tree carries no private key.

Closes #484.
2026-08-11 20:03:02 +08:00
l0ng-ai 00e1aa8218 docs: correct claims that no longer match the code
Audited every page under docs/ against the source. Fixes for what the
code actually does:

- agents: the status vocabulary is idle/working/waiting/done, not
  running/waiting/idle; hook rows grow a separate Uninstall button; the
  Settings table labels read "Copilot CLI" and "Grok Build"; Copy Session
  ID lives in the tab's context menu, not the pane's
- cli: `pane ls --all` reports the owning workspace id, not "tty7-cli";
  document bare `tty7 [PATH]` as the GUI launcher it is instead of listing
  it as unimplemented; note `active_tab` and the `diagnostics` array; wait
  also defaults to $TTY7_PANE
- git: the branch dropdown is a plain list with no search box and no
  stash-and-switch, and checkout is not a palette command; quote the diff
  overlay's own overflow notice rather than the sidebar's
- window: the unread marker tracks a finished agent turn, not any output;
  rows cannot be dragged across groups; the sidebar and `tty7 tab ls`
  resolve labels differently; drop Toggle Commit History and Checkout to
  from the palette's Git group; ~/.ssh/config aliases are not palette
  entries
- terminal: Ctrl+R dedups by command text and shows no directory; Esc does
  not dismiss a ghost suggestion; document Cmd+Enter
- remote: GSSAPI is an ordinary Auth choice, not a managed-connection-only
  mechanism
- fonts: Maple Mono NF CN leads the chain on Windows and Linux only; list
  the real per-platform defaults
- settings paths: the three Links settings and per-pane history were filed
  under the wrong sections
2026-08-11 14:35:54 +08:00
l0ng-aiandl0ng-ai 72060d8257 fix(ui): stop washing out the workspace discs in the switcher (#483)
The monogram disc beside every workspace row was drawn at opacity 0.55
unless that row was the current workspace, and the initial inside it is
already the foreground at 0.65 — so the letter landed at about 0.36 and
went illegible on exactly the rows the panel exists to let you pick
between. Only one row in the list is ever the current workspace; the
other however-many all got the dimmed treatment.

The liveness dot is painted on the wrapper rather than inside the disc,
so it never dimmed with it: a washed-out grey blob with a full-strength
green dot stuck to its corner, which is what made the column look dirty
rather than quiet.

Drop the dimming. Nothing is lost by it — the current workspace already
carries a "this window" badge, a medium-weight name and the selected
background, so the disc was saying a fourth time what three louder
things had said. `current` was the only reason the helper took that
argument, so it goes too.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 11:26:27 +08:00
l0ng-aiandl0ng-ai c4645aeea8 feat(terminal): draw a scrollback scrollbar down the right edge of a pane (#480)
A pane's scroll position lives in alacritty's `display_offset` — rows of
scrollback, not pixels of laid-out content — so it has no `ScrollHandle`
to hand a scrollbar. `TerminalScrollHandle` implements gpui-component's
`ScrollbarHandle` over the grid instead, which lets the pane draw the
same `Scrollbar` the sidebar and every list already use: same theme,
same `Scrolling` show mode, same fade-out.

The bar never touches the terminal. `set_offset` only records the row it
wants; `sync_scrollbar` applies that on the next render — clearing the
sub-line remainder and cancelling an in-flight smooth scroll on the way —
and reports back where the grid actually ended up.

Scrollback piling up at the live edge is deliberately not reported: the
bar shows itself whenever the offset it reads changed, so a pane printing
a build log would otherwise hold a thumb on screen for as long as the
output ran. Every other change passes through, including the history
shrinking, which is a cleared scrollback rather than growth.

Closes #432

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 10:54:12 +08:00
l0ng-ai 9735a490b5 Merge pull request #479 from l0ng-ai/perf/wsl-tab-open-cost
perf(wsl): stop re-proving the distro on every new tab
2026-08-11 10:35:59 +08:00
l0ng-ai d09fc10878 fix(wsl): make the remembered server path safe to trust
The note the last commit introduced had no working way to be wrong. Its only
repair was the router forgetting the distro when `RemoteLink::wsl` returned an
error, and that call only spawns `wsl.exe` — which starts perfectly happily
with a server path that no longer exists inside the distro. The exec failure
arrives later, as an EOF on the bridge, so a distro that was reinstalled or had
its bin directory cleaned out failed every WSL tab from then on, with nothing
re-installing it and no way out but restarting tty7.

So forget it where the truth actually shows up: a bridge that closed without
ever sending a byte never ran, and after one of those the next pane proves the
distro again. The spawn-error retry stays, but only when the path came from
memory — a path proved a moment ago will prove the same, and re-probing it just
doubles the wait before the error reaches the user.

Two more things the note quietly took away.

It was read before `install_lock`, so a pane spawn was no longer mutually
exclusive with `replace_wsl_server`, whose whole job is to move the file the
note names: a window restoring panes while the user updates the WSL server
could spawn the binary being replaced. The read moves under the lock, which
costs nothing when no install is running and correctly waits when one is.

And it short-circuited `Installer::run`, the only thing that notices a foreign
build serving the distro — so the "a different build of tty7-server is serving
this machine" warning reached the first pane of the daemon's lifetime and no
other, including a whole new GUI session, since the daemon outlives one. The
note now carries the mismatch it found and re-files it for each later pane,
which is the same warning without the five round trips that found it.

The wall-clock budget in the remembered-answer test is gone: the returned path
already proves no probe ran, and 200ms of elapsed time on a loaded CI box only
ever proved the box was loaded.
2026-08-11 10:25:43 +08:00
l0ng-ai e0745329db fix(wsl): do not pass off a half-read registry as the distro list
`registry_user_subkeys` ended its walk on any non-zero return and reported
what it had as the answer. Only one of those returns means "that was all of
them"; the rest mean the walk stopped early — a `wsl --unregister` running
right now, a Store install rewriting `Lxss` underneath it — and a failure at
the very first index came back as `Some(vec![])`, an authoritative "there are
no distros". The sweep that feeds the shell menu keeps the last good list only
when the probe says `None`, so that empty answer erased the user's distros for
the length of the TTL, with no error anywhere and no `wsl -l -q` to catch it:
the fallback only runs when the key will not open at all. The walk now says
nothing unless it reached the end.

`State` is now read too, the way Windows Terminal reads it. A `DistributionName`
is not a promise that the distro can be entered: an install that was cancelled
half way, a failed `--import`, one being uninstalled as we look, all leave the
key behind. `wsl -l -q`, which this replaced, never listed those; without the
filter they arrive in the shell menu and open a pane that dies of a WSL
registration error. A key with no `State` at all is still taken at its word,
which is the conservative direction — inventing one would hide working distros,
which is the mistake `Modern = 1` would have been.

That also makes `registry_user_dword` production code rather than a `cfg(test)`
copy of `registry_user_string`'s FFI scaffolding kept alive for one assertion.

The timing test now skips when the registry has nothing to read: on a machine
with no `Lxss` key the listing is *supposed* to go to `wsl.exe` and wait, so
timing it there failed the test on exactly the machines the fallback is for.
And hoisting `LXSS` had left `default_wsl_distro`'s doc comment attached to the
const; it goes back on the function.
2026-08-11 10:25:30 +08:00
l0ng-ai 49bfe59410 docs: drop the orchestration skill tty7 no longer installs
The in-app switch that wrote `~/.claude/skills/tty7-orchestration` was
removed in da6df70, before any release carried it — `git grep` finds it in
no tag. The changelog entry recording it landed the same day, written from
the branch's state rather than the tree's, and the documentation site (#478)
was then written from the changelog, so the feature reappeared on three
pages describing something the app has never shipped.

The orchestration page now says what is true: nothing is installed for you,
the agent-facing contract is `skills/tty7` and you add it yourself with
`npx skills add l0ng-ai/tty7`. That skill covers driving panes; `wait` is
documented on the orchestration page itself, so neither page promises what
the other holds.
2026-08-11 09:52:38 +08:00
l0ng-ai 7f16f6a6ff fix(wsl): read the distro list from the registry, not the WSL service
`wsl -l -q` has to reach the WSL service, and reaching the WSL service is
the part that can be slow. Behind a hardcoded 3s timeout that made the
listing all-or-nothing: on the machine in #454 a round trip took 3.3s, so
the call timed out every time, the list came back empty every time, and no
WSL distro was ever offered in the shell menu. Not slow — absent.

`Lxss` is where `wsl.exe` registers them, it is the same key
`default_wsl_distro` already reads for the same stated reason, and nothing
is listening on it, so it cannot hang. `wsl -l -q` stays as the fallback
for when the key will not open at all, which means this is not a machine
with WSL on it rather than a machine whose WSL is busy.

Windows Terminal made this move in 2021 (microsoft/terminal#10967) after
the same symptom — distros "missing entirely" on first launch. It skips
distros whose key carries `Modern = 1`; we must not. That is a
deduplication rule specific to Terminal, which modern distros hand a
profile fragment of their own. Nothing hands tty7 anything, and on an
up-to-date machine `Modern = 1` is the ordinary case — on the box this was
written on, the only distro installed. There is a test pinning that.
2026-08-11 09:40:37 +08:00