Commit Graph
216 Commits
Author SHA1 Message Date
l0ng-ai 893172f57d fix(control): raise the dialect to v9 rather than walk it back to 7
Reverting the projects layer took CONTROL_VERSION back to 7 with the
verbs it had been raised for. The dialect that number describes is
correct — this build speaks v7's messages again, one for one — but the
number is not, because v8 is already deployed.

A version that moves backwards stops being an identity. A 7 on the wire
would mean "before projects" or "after them" depending on which build
sent it, and the handshake has nothing but the number to tell those
apart: a v8 peer would read our 7 as an older server it may keep
talking to, and every project verb it pushed would fail to decode and
drop the link.

v9 is a number no peer has seen, so a v8 peer is turned away at the
handshake instead. Nothing else changes; every other reference to the
constant is relative to it.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:24:37 +08:00
l0ng-ai 31ce382226 revert: drop projects as a declared sidebar layer (#769)
Reverts cebd871c. The declared layer sat beside the derived repo groups
rather than replacing them, so the sidebar carried two kinds of heading
that look alike and behave differently, plus the verbs to create, rename,
re-root, reorder and delete one. That is more interface than the problem
was worth, and the derived grouping already covers the common case.

Everything the commit touched goes back: the Project entity on the
machine, the per-tab reference, the six control verbs and their layout
deltas, the sidebar rail and tab strip surfaces, the mirror and tree-sync
paths, and the i18n strings.

CONTROL_VERSION drops back to 7 with the verbs it was raised for. A
remote box still serving the v8 build will be turned away at the
handshake until its tty7-server is replaced.

Sessions written while projects existed still load: `projects` and a
tab's `project` are unknown fields now, and serde drops them.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:01:19 +08:00
l0ng-ai be2eb4c086 fix(daemon): clear a stale pane socket before binding it (#779)
A daemon that died without unlinking its Unix socket stopped every later
daemon on that machine from ever starting. The client launched one, it
exited on the bind, the client launched another, forever.

run_with does clear a stale endpoint, but only after a probe it skipped
whenever the recorded daemon was known dead — reasoning that the bind below
would overwrite the file. That is true of a Windows port file, and the whole
of #639, where the skip came from, is Windows ports: it added a connect
timeout so a firewalled stale port fails fast. On Unix the endpoint is a
socket file and bind refuses any path that exists, so the one path that
reached the bind with a leftover still there was the one that skipped its
removal.

What decides the removal is now the single-server seat, not the pidfile.
Holding it means nobody else can be serving this config dir, so anything
still at the endpoint belongs to a process that is gone — safe by
construction, which is the property `singleton` exists to provide. Removing
on one failed connect instead is the race that module was written to retire,
so it is used only where there is no seat, and even there a socket that
answers is refused rather than removed.

Reproduced on a Linux box: with a socket nothing was behind and a pidfile
naming a dead pid, main exits 1 on the bind and this starts and serves.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 10:38:35 +08:00
l0ng-ai e98586bdbd fix(remote): keep the reason a remote server failed to start (#774) (#777)
A remote workspace could sit in a loop nobody could get out of: every
reconnect failed with "started but nothing was answering on the control
socket after 15s", the strip showed a copy bar frozen at 100%, and no
button was offered.

The daemon is the root of it. When its control listener would not open it
logged one line and kept running — and a running daemon holds the
single-server lock, so every later --daemon stood down at once and every
client probe failed, forever. Whether something else is serving cannot be
read off the errno: bind_control_socket clears the leftovers it can, but a
path it cannot clear comes back AddrInUse in the same words a live server
does. Ask by connecting, and exit when nothing answers.

The reason was thrown away twice over: the daemon's stdout and stderr went
to /dev/null, and the readiness probe kept only out.success(). Both are
kept now — output and exit status land beside the binary, stamped with the
launch's own nonce so a restart never reads the outgoing daemon's status as
the incoming one's. A start that has already failed no longer waits out the
full timeout.

The UI half: an automatic reconnect never retired its install progress, and
a leftover entry draws an install in flight instead of the failure and its
button. And a long error stretched the status card to 1978px in a 1440px
window, taking the retry button off the screen with it.

Closes part of #774. The Vim :wq cursor and the btop re-attach items in that
issue are not touched.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-06 23:08:49 +08:00
l0ng-ai cebd871cb4 feat(sidebar): add projects as a declared layer beside the derived groups (#769)
* feat(sidebar): add projects as a declared layer beside the derived groups

The sidebar's repo groups are derived: a group's identity is a path
recomputed every frame from a leaf's cwd, it appears when a tab lands in
it and vanishes with its last tab. That layer cannot carry a name of its
own, cannot be created before a tab is opened in it, and orphans anything
keyed to it when a directory is renamed or moved.

Add a Project as a real entity on the workspace — an id, an optional
name, a root — and an optional reference to one on each tab. Nothing
probes it: a tab joins a project only by an explicit action, and a tab
that leaves one lands back in the group the probe would have put it in,
so declaration and inference never disagree and no third membership
state is needed.

The derived grouping, the cwd probe, the write-back and the
SidebarGrouping config are untouched; the only difference is the tab list
they are fed. A server that predates the projects feature ignores the new
array and serves today's sidebar.

Closes #756

* fix(sidebar): stop a searched row claiming a chord it does not own

A live search deliberately ignores a folded heading — the query is asking
about tabs — so the rail draws rows the chord order has taken out. The badge
was read from a `Vec<usize>` that started at zero, so every one of those rows
claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by
`badge_positions` off the same order `activate_visual` walks, and a row the
order left out wears no badge at all.

Also in the rail: the block loop reads "declared" off the section key rather
than the position it happens to sit at, and an unreachable `continue` for a
folded empty derived block is gone — `sidebar_sections` never makes one.

Projects:

- `MAX_PROJECTS` is held on the window side too. The machine refuses past it
  and a refusal resynchronizes, which would re-push the project this window
  kept and be refused again. Checked before the folder panel opens, so a full
  workspace says so before asking for a folder rather than after.
- `set_project_root` keeps the one-project-per-directory rule `declare_project`
  holds on the way in; pointing one project at another's folder reached the
  two-headers-that-mean-the-same-thing state by the back door.
- Opening a rename box over one already on another project commits it instead
  of dropping it with its subscription, which threw the typing away.

Sync:

- Project reordering moves after `retire_projects`. `to` indexes the machine's
  whole list, so a project on its way out pushed the survivors along and spelled
  a move for one already in place.
- `adopt_projects` reports whether it changed anything and the callers repaint
  when it did; it was mutating the window's list with nothing to notify.
- `migrate_panes` gets its doc comment back — `reconcile_projects` had been
  inserted between it and the comment describing it.

Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere.

* fix(control): move the dialect to v8 for the project verbs

`CONTROL_VERSION`'s own doc says to move it whenever a variant is added to
`ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings
are not a substitute: they cover what a peer can safely ignore — a field added
to a message it already decodes — while a variant it has never heard of fails
to decode and takes the whole link down with it.

The project verbs shipped behind a `projects` feature string instead. That
gates what a client *sends*, so a v7 server never saw a verb it could not read,
but nothing gates what a server *pushes*: a v7 client meeting a v8 server that
had grown a project would take the `ProjectCreated` delta, fail to decode the
frame, and lose the link — `read_until_closed` calls `fail_all` on any decode
error. Only the number can turn that pairing away at the handshake.

So the number moves and the feature goes. It was redundant even for the
direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the
same `services.machine.is_some()`, so within one build they were always equal
and only a cross-version pairing could tell them apart — which is exactly what
v8 now refuses at the handshake. Keeping both would be two mechanisms for one
job, and the weaker one silently covering half the problem.

Removed with it: `is_project_op` and the `pump` filter it fed.

Disk compatibility is a separate axis and is untouched — `Workspace::projects`
and `Tab::project` keep their `serde(default)`, and the test that reads a tree
written before either still passes.

Remote workspaces need their `tty7-server` pushed before they will connect.
That is the dialect-refusal path v7 was minted to make reachable: the parked
strip and its Update Server button.

Also: the two sidebar `+` buttons now fade in on their own heading's hover
rather than the whole rail's, so a control appears where the pointer is.
2026-09-04 21:19:05 +08:00
l0ng-ai cd70338486 fix(daemon): let a clean version probe clear the mismatch record (#770)
* fix(daemon): let a clean version probe clear the mismatch record

The restart prompt was armed from a global that only ever accumulated:
`note_daemon_mismatch` could set it, and nothing could take it away
except the window that consumed it. `ensure_running`'s agreeing path
never touched the record at all.

That matters because `ensure_running` is the first thing every
control-link reconnect attempt runs, and a mismatched daemon is one no
connect succeeds against. The link backed off and retried, arming the
prompt again each time round — including in the seconds the user spent
reading the dialog it had already opened. Restarting the daemon then
fixed the daemon and not the record, so the next window built took that
last arming and asked a second time about a server that was already
gone.

Make a probe's verdict settle the record rather than only add to it: a
daemon found to be ours wipes what an earlier probe left. The probe
judgement moves into `judge_probe`, and the handoff's own return
judgement into `judge_handoff_return` / `land_handoff_return`, so both
are testable apart from the sockets it takes to reach them.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): tell a silent control socket apart from an agreeing one

`control_dialect_refusal` returned `Option<DialectRefusal>`, and `None` meant
both "it answered with our own dialect" and "it never answered at all" — a
connect that failed, the handshake timing out, a peer that hung up before
`HelloOk`. That conflation was harmless while silence only meant "record
nothing". It stopped being harmless when a clean verdict started *clearing* the
mismatch record: a control socket that times out now wipes a refusal the
control link had already met, and the window built next opens with no tabs and
nothing on screen to explain why — the exact state the record exists to
prevent. The function's own doc comment still promised the opposite.

Give the answer three shapes (`DialectAnswer::{Agrees, Refuses, Silent}`) and
carry the distinction through to the verdict (`MismatchVerdict::{Clear, Found,
Unchanged}`). Only `Agrees` clears. Silence leaves the record exactly as it
stands, which is the cheap side of the asymmetry: a stale record costs one
prompt about a daemon that turned out fine and the next probe takes it away,
while a wrongly cleared one costs a window its tabs.

The landing also moves out of `ensure_running` into `land_probe`, so the
logging and the record write are one thing a second caller can reuse.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): settle the mismatch record on the restart path too

`ensure_running` only settles the record on the branch where a daemon is
already listening and answers. `restart()` is `stop()` + `ensure_running()`, so
by the time it runs the connect is refused, the endpoint is reaped, a daemon is
spawned, and the startup poll loop returns having written `note_local_daemon`
and nothing else. The record still describes the daemon the user just killed.

That is the path a mismatch is most likely to take. A daemon from before
protocol versioning reports no version at all, so `local_daemon_supports`
answers false for the handoff feature and the prompt's Restart takes
`restart()`, not `hand_off()` — the one branch that was given a clear.

Judge the freshly spawned daemon with `judge_probe` and land it with
`land_probe`, the same pair the already-running branch uses. The control
listener is up before the pane endpoint binds, so the dialect can be asked this
early; a daemon that answers neither handshake is still recorded as nothing,
same as before.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): drop a probe verdict about a daemon that is already gone

A probe is not one instant: it connects, asks the pane endpoint and the control
socket, and only then writes what it found. In between, this build can stop the
daemon, hand it off, or spawn a new one — and now that a verdict *settles* the
record rather than only adding to it, a late one is not a stale read but a
wrong write.

Against a mismatched daemon that is the normal case, not a corner. The control
link retries on a backoff and every retry runs `ensure_running`, so when
`land_handoff_return` clears the record, a probe that connected to the outgoing
image before the exec lands afterwards and re-arms the prompt about the daemon
the user just replaced. The clear was best-effort against its own retry loop.

Stamp each verdict with a counter that moves whenever this build deliberately
changes which process serves — `stop`, `reap_stranded`, `spawn_detached`, and
the handoff at the point the exec is asked for — and drop a landing whose stamp
is stale. Probes against the same daemon are still last-one-wins, which is what
a record of "what is running now" should do.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
2026-09-04 15:58:59 +08:00
ayamirandl0ng-ai e231b16fb3 feat(ssh): allow remote image clipboard writes (#766)
* feat(ssh): allow remote image clipboard writes

* fix(ssh): keep a profile's clipboard grant across a re-attach

A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".

Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.

Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-02 14:12:28 +08:00
oldhuandl0ng-ai 0b6c332618 feat(settings): add interface font family configuration in appearance typography (#761)
* feat(settings): add interface font family configuration in appearance typography

* fix(settings): let the interface font go back to the system face

Three things the new **Interface font family** row spelled once and needed
twice.

`apply_theme` only wrote `Theme.font_family` when the setting was `Some`,
and `Theme::change` never puts it back — it rewrites the field only when a
theme config names a face, and none of ours does. So picking a font worked,
and picking **Default** back saved `None`, redrew every window in the font
the user had just cleared, and only came true at the next launch: a setting
that looked like it had applied instantly and had not. The face is now
assigned in both directions, against the stock value read once before
anything overrode it.

The dropdown's first row borrowed the bold/italic label, "Default (match
primary)" — which promises the *terminal's* primary family. The interface
falls back to the system UI font instead, so the row said the chrome would
come out in Hack while the description beside it said the opposite. It gets
its own label in all three locales.

`ui_font_family` was also the one key in `config.json` that disappeared when
unset; every other optional key is written as `null`. Dropped the
`skip_serializing_if` so the file still lists it, and documented the key in
the two tables that enumerate the typography settings.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-02 14:07:59 +08:00
webdev c23483ba85 fix(ssh): close every channel tty7 abandons before the server does (#715) (#727)
A remote link worked for a while, then every operation on it failed with
"could not identify the remote machine: could not open a command channel:
Failed to open channel (ConnectFailed)", and Try Again only made it worse.
sshd was refusing the session channel: its stock MaxSessions is ten, and
tty7 had left ten open on the cached connection.

russh closes a channel in exactly one case. When the server sends
CHANNEL_CLOSE first, the session task answers it on arrival. Dropping a
`Channel` sends nothing — the one close-on-drop it has sits behind
`into_stream`, which the remote link and SFTP already ride and which a
command whose output is read with `wait` does not. So a command that ran
and exited cost nothing, and a channel abandoned while the far side was
still running it cost a session for the life of the connection. There
were four ways to abandon one. The installer's `exec` returned early on a
failed exec request, and more to the point was dropped mid-drain by the
timeouts in `run` and `spawn_detached`: a `uname` that hangs or a daemon
launch that does not answer within its budget is what those timeouts are
for, and each one pinned a session. The shell and env probes broke out of
their drain on EOF or at their output limit and dropped the channel. And
`drive_channel`, the pane's own shell, closed only on the pane's Close: a
pane whose reader had gone while the shell still ran broke out of its
loop and left that shell's session held for as long as the cached
connection lived. That last one is the "after some use".

A command now rides a `CommandChannel`, which closes on drop: the `?`
after the open, the normal return and the timeout's cancellation all
queue the CHANNEL_CLOSE for the session task, the way russh's own
close-on-drop does. The runtime it spawns on is taken at construction,
on the runtime by definition, rather than looked up from whichever
thread the drop lands on. The probes ride the same type. `drive_channel`
closes after its loop on every exit; after a close the server sent
first, russh has already taken the channel out of its table and the
redundant EOF and CLOSE put nothing on the wire.

The safety net, for a leak this change did not find: a connection whose
session open comes back ConnectFailed marks itself dead, and `is_alive`
is what the cache consults before handing a connection out again, so the
next Connect — Try Again included — dials afresh instead of retrying a
link that will refuse forever. It is not the fix: a fresh connection to
a leaking client is ten operations from the same wall. The shell probe
also no longer remembers a "no integration" it got from a link that
refused it a channel, which would have kept integration off that host
for the rest of the run.

The install layer is tested against `FakeRemote`, which has no wire, so
none of this was visible. An SSH server now runs in the test process —
russh's server half, accepting every session up to a limit and answering
`exec` as a command that exits or one that hangs — and counts the
channels the client opened and closed. It shows a timed-out command
closing its channel, twelve abandoned commands against a limit of ten
with none refused, a finished command's close answered exactly once, a
gone pane closing the shell behind it, and a refused open retiring the
connection. Each was checked against the old code. What it cannot show
is sshd's own accounting; the reporter did that, with a paramiko script
that exec'd freely while closing each channel and was refused on the
eleventh it left open.

Diagnosis and reproduction by xAlisher.
2026-08-26 20:11:52 +08:00
l0ng-ai ba6760c6f9 fix(restore): carry the pane's title in the snapshot so a restored tab keeps its name (#725)
A tab's title only exists as live terminal state: the OSC that set it was
emitted screens ago, and the on-disk snapshot is capped at 256 KiB, so the
bytes that would restore it are almost always trimmed away. Since #681 made
a silent attach fall through to a fresh spawn, a slow daemon on reopen turns
every such tab into the default "tty7".

Store the pane's last OSC title beside the snapshot's segments (as a
trailing field old readers skip and old files simply lack), and replay it as
a fresh BEL-terminated OSC 0 -- control bytes stripped so a stored title
cannot terminate the sequence early -- before the restore preamble. The new
pane's daemon record inherits the title too, so the switcher and CLI agree.
2026-08-25 16:54:56 +08:00
l0ng-ai 975e3edf9b Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn

Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.

Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.

"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.

RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".

Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".

Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.

* refactor(completion): stop declaring spec fields nothing reads

The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.

* refactor(daemon): delete the loopback-forward management pipeline

Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.

The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.

known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.

* test(ssh): cover the host-key policy table and both proxy handshakes

The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.

socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.

* test(cli,daemon): cover server binary resolution and the procargs parser

server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.

parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.

* test(ui): cover the host-op pool decisions and the local reconnect schedule

The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.

LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.

* fix(completion): unquote across the whole word, not just its first character

The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.

The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.

* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect

`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.

The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.

* test(file-tree): name the shell where the quoting rule is the POSIX one

`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
2026-08-20 23:33:26 +08:00
l0ng-ai 07e3b26434 feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)
* feat(agent): outline a coding agent's conversation, and jump back to a turn

The hooks tty7 installs into Claude Code already announce every turn over the
pty as an OSC 777, and the daemon reads those for the pane's status dot. The
same bytes reach the client, where they are worth something else: the byte
offset a `prompt-submit` lands on is a *position in the stream*, so advancing
the emulator to exactly there and reading the cursor gives the scrollback row
that turn began on. That is an outline of the conversation, and a way back into
it — which is the one thing a long agent session in a terminal has never had.

The Info panel grows a CONVERSATION section: one row per turn, the prompt's
first line as its label, a dot that says whether the turn is still running.
Clicking a row scrolls the pane so that turn's prompt is the top line.

Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no
room for one at 260px, and a fourth variant would drop anyone who rolled back
to an older build onto Info. This is a fact about the pane, like its shell and
its cwd, so it sits with them.

The hook is a subprocess writing to the controlling tty while the agent's own
renderer writes to it too. Claude Code repaints in place with ink, so the cursor
when the hook's bytes land is wherever the last repaint left it — inside the
live region, a few rows from where the prompt's echo comes to rest. And once
the scrollback limit starts discarding lines, every anchor slides by the discard
count at once.

So the anchor is a hint, and the prompt's own text is the correction: at click
time (by which point it has long been drawn) the row is looked for around the
anchor, exact match first — the row that *is* `> hi`, marker stripped — and only
then by containment, which keeps its length floor because `hi` appears inside
half the rows of any answer. The row that is found is written back, so a second
click does not search again and cannot land somewhere else.

Claude Code keeps a JSONL transcript, and reading it would give the assistant's
side too. It would also only work for Claude, only when the agent runs on this
machine, and only for a path this process may read. An OSC comes back through
the pty from wherever the agent actually runs — over ssh, in a container, in a
remote workspace — with no file access and no per-agent format. What is lost is
the assistant's text; what is kept is every host tty7 supports.

- `OscTokenizer::feed_at` reports each payload's end offset. The client already
  tokenized OSC 777 on every batch to keep agent events out of desktop
  notifications, so the scan is free; only a real event now costs a cut, which
  is what #404 was right to object to about the old per-command mark scanner.
- `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending
  runs concatenated are not one, so a batch carrying both kinds is sorted —
  and only such a batch pays for it.
- A replayed ring is cut the same way, so reattaching to a pane rebuilds the
  outline from its own history rather than losing it with the old client.
- Turn anchors are dropped where image placements are: `clear_scrollback`, and
  the grid reset in `adopt_relink`.
- A turn that began on the alt screen is listed but not clickable — there is no
  scrollback behind it to return to.
- A turn announced twice is one turn. Hooks are not guaranteed to fire once,
  and what makes it the same turn is that the one before it never ended: a real
  repeat can only come after an answer, and an answer brings a `stop`.
- The hook forwards the prompt's first line, clamped to 200 characters. The
  tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a
  pasted file would otherwise cost the whole event; and a needle spanning a line
  break matches no single row.

No protocol change: the prompt rides in the OSC the hook already sent, and an
older client ignores the field.

* refactor(panel): drop the Info panel's agent row

It said `Claude Code · working` behind a status dot — the same name and the
same dot the tab chip and its sidebar row were already wearing, restated two
panels away from either of them. The CONVERSATION section that now sits under
it says what the agent is doing in a form the row never could: which turns
there were, which one is still running, and a way back to each.

`InfoValue::Agent` and `status_pip` went with it — the dot was the row's only
caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three
status labels stay: the tray menu still names them.

`Tab::agent_row` stays too. `agent_status` is that pair's status and the tab
strip's badge reads it, which is the one-leaf rule #543 put there.
2026-08-20 21:56:34 +08:00
l0ng-ai 8131ac2f93 Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
2026-08-20 18:28:48 +08:00
Austin Spragginsandl0ng-ai 2cdc26f357 Wire hooks, resume and detection for Kimi Code CLI (#694)
* feat(agents): wire hooks, resume and detection for Kimi Code

Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.

Closes #693

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>

* fix(agents): harden the Kimi Code TOML hook merge and its resume flags

The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.

`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.

A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.

`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.

On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.

Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.

---------

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-20 09:51:05 +08:00
l0ng-ai 958d8b7442 feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)

Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.

They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.

`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.

- Half the terminal column by default; drag the divider, double-click it
  to cycle a third / half / two thirds, or use the palette commands. Two
  thirds deliberately runs past the half-window cap the side panels obey
  — only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
  the way they already reserve each other, and the column is derived from
  the *live* sidebar and panel widths rather than their floors, so a
  panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
  derived at render time and never stored, so widening re-docks on the
  next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
  file over the whole window in one tab while an agent keeps half of
  another is the normal case, and one global switch made each of those
  flip the other. A tab that has not been told reads `document_layout`
  from the config, which is what a fresh tab starts as — and which the
  menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
  bar's height of nothing above the column. The header is drawn into it,
  and behaves like the title bar it now sits in. With the detail panel
  closed the column reaches the window's right edge, so the header stops
  short of the trailing chrome through a width the tab strip's own
  reservation shares.
- The docked headers drop the traffic-light inset they never had to
  clear, and the diff header's branch name becomes the thing that yields
  so the view toggle and the close tile survive a column's width.

New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.

* fix(window): hold the docked column to widths the strip and the file agree on

Three defects in the document column, each with a guard test that fails
without its fix.

The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.

The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.

The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.

Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
2026-08-19 18:03:51 +08:00
l0ng-ai ef333bf055 feat(terminal): make the wheel-zoom modifier configurable (#676)
Cmd-scroll zoomed the font with no way to move it or switch it off, so a
thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is
now a setting: the platform modifier by default, or Ctrl, Alt, or none.

Stored as the choice rather than the resolved key, so one config file
still means the same thing on a Mac and on a Linux box. Settings ->
Terminal -> Mouse carries the picker; off macOS Ctrl and the platform
modifier are the same key, so it shows one cell for them.
2026-08-18 12:16:17 +08:00
l0ng-ai 8b5aeb0077 Wire hooks, resume and fork for the CLI agents that support them (#666)
* feat(agents): hook, resume and fork support for nine more CLI agents

Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their
own settings.json the way Claude and Codex already do; Goose gets an
owned file under the Open Plugins layout it implements. Qwen is the only
one of them with a first-class PermissionRequest event, so it needs none
of the notification sniffing the others do -- and deliberately gets no
Notification hook at all, since that event fires for non-blocking alerts
too and would strand a pane on "waiting".

Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork`
is a real subcommand that is simply missing from `amp threads --help`.

Four detection and replay bugs turned up while checking each CLI:

- `python3 -m antigravity`, the documented way to trigger Python's own
  easter egg, was detected as a coding agent. The `antigravity` binary
  is the IDE's launcher shim anyway, in the shape of VS Code's `code`,
  not the terminal agent -- that one is `agy`.
- Amp lost every launch flag on resume. It names a thread with a
  positional argument, so the stale-flag list had nothing to drop and
  the generic bare-token check rejected the whole tail along with it.
- Gemini could be handed a command line it refuses to start from:
  `--session-id` and `--session-file` are mutually exclusive with
  `--resume` and were never stripped.
- Cursor's `--continue` was not stripped either, leaving it to collide
  with the injected `--resume <id>`.

Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now
come from first-party sources -- logo SVG fills and site CSS variables
-- rather than approximations. Qwen ships its real mark instead of the
generic bot glyph.

Hooks stay unwired for Aider (no lifecycle mechanism exists at all),
Cursor (its usable events gate permissions, and tty7's silent hook would
read as a failed check and auto-allow the command), Auggie (its command
field takes only script paths, needing generated wrappers, and the
constraint could not be verified without a billed run), and for Hermes,
Amp, Vibe and Antigravity, whose event sets are too thin to report a
blocked turn.

* fix(agents): strip every session-naming alias before replaying launch flags

Goose spells --session-id also as --id, --name as -n, and keeps a legacy
--path, all in one exclusive clap group; Qwen rejects --session-id next
to --resume; Vibe shortens --continue to -c. Any of these surviving a
replay broke the regenerated resume command. Qwen's --no-chat-recording
also persists nothing, so it now opts the pane out of resume and fork
like Auggie's --dont-save-session. The Qwen icon gains the 24x24
width/height every other agent mark carries.
2026-08-18 00:42:56 +08:00
l0ng-ai f4c31222a4 feat(cli): restart the server in place by default, keeping sessions (#669)
* feat(cli): restart the server in place by default, keeping sessions

`tty7 server restart` used to be stop + start, killing every pane, while
the GUI's Restart Server hands the daemon off to a new image via execve
and keeps everything running. Same verb, opposite side effects.

The CLI now probes the daemon for the handoff feature and asks it to
exec the tty7-server binary in place: same pid, same ptys, sessions
survive. Success is judged by the version endpoint answering with a new
per-process instance id, not by build strings, since the CLI and server
binaries can be on different versions.

A refused or stalled handoff leaves the daemon untouched and reports an
error suggesting `--hard` instead of silently killing sessions. The
stop + start path remains for `--hard` and for daemons that cannot
exec themselves (Windows, pre-handoff builds).

* fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended

After a taken handoff, the poll timing out does not mean the daemon died:
the singleton lock survives the exec, so a held seat is the new image
still coming up with every session aboard. Falling back to start() there
would grant it one second of grace and then reap it — bail with the seat
still held instead, and only start over a genuinely free seat.

The stop-and-start fallback (--hard, Windows, pre-handoff builds) now
reports that sessions ended instead of relaying start()'s plain report,
since the default restart's promise is sessions kept.
2026-08-17 22:59:54 +08:00
l0ng-ai 3dc63e2d87 fix(daemon): find and reap a seat-holding daemon that lost both its names (#671)
A daemon can survive quit-and-stop with its endpoint unlinked and its
pidfile gone while still holding the singleton seat (#667). Every later
launch then spawns a daemon that stands down against the lock and times
out red, and nothing on the machine can recover: stop answers "not
running", ensure_running reaps only through the pidfile, and flock
cannot say who the holder is.

Two roads led there, and both are closed:

- The reap identified a daemon by proc_pidpath alone, which fails
  outright for a live process whose binary was deleted — every nightly
  update replacing the installation. The identity check now falls back
  to the kernel's comm name (proc_name on macOS, /proc/pid/comm on
  Linux, both recorded at exec and immune to deletion), strips Linux's
  " (deleted)" marker, and — decisively — no longer deletes the
  pidfile of a live process it cannot identify: the record was the only
  handle left on the survivor.

- When the pidfile is gone entirely, the pid the claimant now writes
  into daemon.lock at claim time is the handle of last resort. The lock
  file is never deleted and holding the flock is the definition of
  being the server, so while the seat is held its content names the
  holder; stop() and the reap fall back to it, and a confirmed reap
  clears the record (only under a momentarily-free seat) so a stale
  number cannot outlive its process. Unix-only: the Windows seat is
  share_mode(0), unreadable while held.

Every road back now clears a stranded seat, not just the GUI's:
ensure_running's stale cleanup is factored into spawn::reap_stranded,
tty7 server start runs it too, and tty7 server stop no longer takes
"nobody answered" for "nothing to stop" when the seat is still held.
A short grace keeps the reap away from a daemon that is merely
mid-handoff or mid-startup — where health is an answered handshake,
never a bare connect: a wedged daemon's listener still completes
connections out of the kernel's backlog. The startup-timeout errors
name the seat-holding pid, with the kill advice identity-gated so a
stale record never tells anyone to kill an innocent process.

Two liveness corrections round it out: a zombie now reads as dead — it
answers kill(pid, 0) like the living but holds no lock and no image,
and no signal can end it, so counting it alive spent both reap timeouts
on a corpse (the GUI never waits on the daemons it spawns, so crashed
daemons are zombies as a rule) — and stop() only pays the
process-exit wait for a shutdown it actually delivered, instead of
watching an unreached survivor not move for five seconds.

The guard tests were each verified to fail against the behavior they
pin (fallbacks, the handshake criterion, the grace, and the wait gate
removed by mutation) before being trusted green; the zombie probe
semantics (proc_pidinfo failing for a zombie that still answers signal
0) were measured, not assumed.
2026-08-17 15:11:16 +08:00
l0ng-ai 0295a98915 feat(sftp): open remote text files in the built-in editor
A click on a file in the SSH Files panel used to start a download; the
only way to change a remote file was download, edit, re-upload. Now a
click opens it in the built-in editor and Cmd-S saves straight back over
the pane's own SFTP channel, matching what the Files panel already does
locally and over a remote workspace.

- protocol: SftpOp::ReadFile/WriteFile and SftpOpResult::File, bytes as
  base64; the reply carries the body plus the stat it was read under
- daemon: ReadFile enforces the caller's size ceiling before and during
  the read; WriteFile rewrites in place (truncate, not temp-and-rename)
  so the file keeps its mode and ownership
- SftpHost: a Host over the pane's SFTP route, so the editor's existing
  open/save path works unchanged; git/search/watch honestly Unsupported
- editor: an open buffer holds the host it was read from, and
  save/reload/dedup/watch key on (host, path) instead of the active host
- panel: single click opens (dirs navigate, text files edit), the same
  gesture as the local tree; binary or oversized files get the local
  tree's toast, and Download moves to the context menu

Review follow-ups, in this PR: the SFTP host stays out of HostRegistry,
which means "a machine this window has a link to" and is swept as such —
filing the pane's channel there made Cmd-S return silently once a
workspace deletion took it back out. The cursor-jump lookup, the status
bar's path, and the SCM panel's repository all key on the buffer's own
host now. Closes #656.
2026-08-16 18:53:27 +08:00
l0ng-aiandl0ng-ai ccd21fe97d fix(windows): keep a restored screen out of ConPTY's viewport, and stop Restart Server crashing the window (#657)
* fix(restore): keep a restored screen out of ConPTY's viewport

On Windows a restored pane came back with its shell drawing in the wrong
place: the prompt stopped responding where it stood and the restored text
filled with fragments of whatever was being typed.

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

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

So the restore preamble now ends by scrolling the restored screen out of
the way. `ESC[2J` on the primary screen scrolls the viewport into history
rather than erasing it, so the screen the daemon restored is one scroll up
rather than gone, and `ESC[H` leaves the cursor where a fresh ConPTY
expects to find it. Unix keeps the old behaviour: a shell there positions
itself relatively, so the restored screen can stay where it can be seen.

* fix(restart): stop Restart Server taking the window with it

Clicking Restart Server made the whole app disappear, with a double-lease
panic in the crash log: cannot read Tty7App while it is already being
updated.

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

Split into `settle_after_restart`: the window's own state first, then the
resync outside the lease, then the focus. The resync still runs either way
the restart went, because a refused handoff leaves the daemon serving the
panes this window already dropped (#554).

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 17:49:54 +08:00
l0ng-aiandl0ng-ai 0df604054d fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop (#655)
* fix(daemon): keep a lingering daemon findable and reapable after quit-and-stop

Quit-and-stop could strand a daemon that had already unlinked daemon.sock
and deleted daemon.pid but never finished exiting: libc exit() runs atexit
handlers and static destructors beside dozens of live threads, and a
finalizer that blocks leaves the process holding the singleton lock with no
name on disk. Every later launch then spawns a daemon that stands down
against the lock and times out red, forever.

Three changes, each a fallback for the others:

- on_shutdown keeps the pidfile: once the endpoint is unlinked it is the
  only handle anything has on a process that is not gone yet. A pidfile
  that outlives a clean exit was already handled by recorded_daemon_is_dead
  and the reap path.
- The daemon exits through _exit(2) (after flushing the logger), skipping
  the atexit/destructor window entirely; everything owed to disk is flushed
  explicitly in on_shutdown.
- spawn::stop reaps with the pid it captured before asking the daemon to
  die, instead of re-reading a pidfile an old build's shutdown may have
  wiped mid-stop; reap_recorded_daemon keeps the pidfile when the process
  survives even SIGKILL, so the next attempt still has someone to reap.

* review: fix stale stop() comment, pin the mid-stop pidfile-vanish ordering in the test

The comment at the top of stop() still claimed a clean shutdown removes
the pidfile, which this branch just made untrue; it now states the real
reasons the pid is captured early. The vanishing-pidfile test now asserts
the sweeper's delete actually landed while stop() was waiting, so a
future shrink of PROCESS_EXIT_TIMEOUT cannot silently turn it into a
weaker scenario.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-16 17:35:39 +08:00
ARNOandl0ng-ai 2e6103cf19 Retire to the tray on window close; cold start no longer stalls on stale daemon files (#639)
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files

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

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

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

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

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

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

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

---------

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

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

fmt

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

The wrapper's `--config` replaces the user's config.nu entirely, so the
path it sources back must be the one nu itself would load — anything
else silently strips macOS panes of their prompt, aliases and
keybindings. `dirs::config_dir()` is `~/Library/Application Support`
on macOS, not `~/.config`, and nu-path consults `$XDG_CONFIG_HOME` on
every platform (Windows included) but only when it is non-empty and
absolute. The resolution now mirrors that: a per-platform default plus
XDG winning only in the exact shape nu accepts.
Also gate the OSC 7 backslash translation on Windows
(`$nu.os-info.name`) so a Unix path with a literal backslash survives,
and harden the real-PTY tests: one line per submitted command
(reedline drops input while a command runs) and a grace period after
the D mark so the cwd report that follows it in the same prompt cycle
lands in the transcript.
2026-08-14 22:32:47 +08:00
l0ng-aiandl0ng-ai f08d8c2764 fix(remote): stop the server on machines that have no /proc, and show the install on the strip (#627)
* fix(remote): stop the server on machines that have no /proc, and show the install on the strip

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

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

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

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

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

Three things the no-/proc fix left standing.

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Closes #620.
2026-08-14 15:57:36 +08:00
webdev 0346e35b40 fix(shell): stop injecting into a zsh or fish the user gave arguments to (#629)
The zsh and fish arms of `shell_integration::setup` never checked `has_custom_args`, so a shell the user launched with their own arguments was injected anyway — fish had `-C <script>` appended to its argv, zsh had its ZDOTDIR swapped. Both arms now sit behind the same gate bash, PowerShell and WSL already used, hoisted to a single early return ahead of the dispatch so a new ShellKind cannot silently reintroduce the bug.

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

Part of #624; the native-input-mode half is separate.
2026-08-14 15:57:20 +08:00
l0ng-aiandl0ng-ai 72db26d15a feat(prompt): let the shell's own line editor own the prompt (#633)
Closes #624

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

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

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

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

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

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

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

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

The rest:

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

Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
2026-08-14 08:42:29 +08:00
l0ng-ai 84f9d54ad6 fix(remote): finish the create a server update interrupted, and retire the note it answers
Creating a workspace on a machine whose server is the other side of a
dialect bump took two creates and two update clicks in different places.
Two holes in one flow:

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

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

Three tests, each confirmed to fail without the change it guards. The
end-to-end one drives finish_connect against a real control server over
a socketpair, so the parked create is spent by the same code path a
live reconnect uses.
2026-08-13 22:20:43 +08:00
l0ng-aiandl0ng-ai 8296161b4a fix(remote): give a dialect refusal a way out instead of a retry loop (#617)
* fix(remote): give a dialect refusal a way out instead of a retry loop

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

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

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

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

Review follow-ups on the dialect-refusal parking.

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 19:56:21 +08:00
b2d73ec68b feat(update): update an all-users Windows install through one UAC prompt (#562)
* fix(update): surface a failed install instead of silently re-prompting (#540)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also give the Japanese elevation notice its closing 。

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <l0ng-ai@users.noreply.github.com>
2026-08-13 18:13:20 +08:00
Hongwei Qinandl0ng-ai a2d53a9597 fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 18:11:17 +08:00
l0ng-aiandl0ng-ai 3c0a700907 feat(switcher): flat workspace list, create form, connect-time workspace sync (#616)
* feat(switcher): flatten the workspace list, add a create form, sync remote listings on connect

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:02:58 +08:00
l0ng-aiandl0ng-ai 60ef3fb758 fix(shell): name the user, host and home in a Unix pwsh pane's title (#611)
The PowerShell integration read its identity from USERNAME, COMPUTERNAME and
USERPROFILE. Those three spellings exist only on Windows; on macOS and Linux
they come back empty, so every pwsh pane there was titled `@:` followed by a
full un-abbreviated path, and the `~` shortening never fired at all. Read them
through .NET instead, and take home from PowerShell's own $HOME, which is
correct everywhere.

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

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

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

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:14:49 +08:00
l0ng-aiandl0ng-ai 6c26b35acc fix(control): bump the dialect to v6, and publish a tty7-server for macOS (#605)
* fix(control): bump the dialect to v6 so an out-of-date server says so

The control dialect has been renamed, extended and cut since it was
last numbered, all of it against CONTROL_VERSION 5: the machine tree
replaced WorkspaceList/Get/Put/Delete with WorkspaceTree, MachineGet
and the tab/pane verbs, GitStream arrived with its chunk and end
events, and ReplyOk::Attached and FileMeta went away.

A peer left behind by any of that still answers the hello, because the
number it answers with still matches. It is also still sitting at the
path the installer looks for, tty7-server-c5p5, so a client decides it
already has the server it needs. Then the first call reaches a variant
the peer has never heard of, the frame fails to decode, and the read
loop takes the whole link down with it. What the user sees is a remote
workspace that opens with no tabs and a git detail pane that never
fills, with nothing anywhere saying why.

Moving the number puts all three guards back: the hello is refused with
the message that names the old build, the remote binary is looked for
at c6p5 and installed rather than trusted, and a stale local daemon
gets the restart prompt it should have been getting all along.

Document the rule next to the constant while it is fresh: move it when
a variant is added or removed. The feature strings only cover what a
peer can safely ignore, and a request it cannot decode is not that.

* feat(remote): publish a tty7-server for macOS hosts

A remote workspace has been Linux-only for no reason anyone chose: the
installer derives the asset name from `uname -sm`, and the only names it
knew were the two musl builds. A Mac on the other end of an SSH profile
got "a remote tty7 workspace needs a Linux host" and stopped there.

Publish the two Apple slices alongside them and teach the installer to
ask for them. `Darwin arm64` and `Darwin x86_64` now map to
tty7-server-macos-aarch64 and tty7-server-macos-x86_64; everything past
that point already worked, because nothing under it was ever Linux-
specific — the install path is POSIX, the upload is SFTP, and the
dialect probe runs the binary before trusting it.

The machine names are matched per system rather than by architecture
alone. Linux says aarch64 on one distribution and arm64 on the next,
while a Mac only ever says arm64, so honouring Linux's spellings under
Darwin would be guessing at output no Mac produces.

Static linking is not the instrument on macOS — Apple ships no static
libSystem — so assert-macho.sh stands in for assert-static.sh with the
guarantee that actually matters: every dependency resolves under
/usr/lib or /System/Library, so nothing the destination Mac lacks can be
picked up from a build runner, and the binary carries the signature
arm64 refuses to run without.

Not signed or notarized beyond that, deliberately. The binary is never
downloaded by the Mac that runs it: the client fetches it, verifies it
against checksums.txt and writes it over SFTP, which sets no quarantine
attribute, so Gatekeeper is not in the path.

ASSET_X86_64 and ASSET_AARCH64 become ASSET_LINUX_*, which is what they
always meant and could not keep meaning next to a macOS pair.

* fix(ci): sign the x86_64 macOS server, and stop the guard flaking on it

Two faults the first green run hid from each other.

The linker ad-hoc signs the arm64 slice because Apple Silicon will not
execute anything unsigned, and leaves x86_64 bare. That is fine on an
Intel Mac, but the x86_64 server is also what an Apple Silicon box gets
when it asks through a Rosetta shell, and handing that machine an
unsigned binary is a guess about Rosetta nobody needs to make. Sign both
slices ad-hoc in the workflow — no identity, no secrets, nothing to do
with the notarized signing the GUI bundles get.

The guard that caught it was itself unreliable: `codesign -dv | grep -q`
under `pipefail` reports failure whenever grep wins the race, because -q
exits on the first match and the writer takes SIGPIPE. Small output means
the writer usually finishes first, which is why the arm64 job passed and
x86_64 failed on the same signed-or-not question. Capture into a variable
and match afterwards, the way the release workflow already does it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:47:27 +08:00
l0ng-aiandl0ng-ai 49901d7f8a fix(cli): let --enter press the key it is shorthand for (#581) (#606)
`--enter` is documented as sugar for `--key enter`, but the send dispatch
counted only `args.keys`, so `tty7 send %42 --enter` answered "needs TEXT
... or a --key to press" and pressed nothing. The key list is now built
before the dispatch and the dispatch counts it, so a marked address with
`--enter` and nothing else runs what the pane already has typed, and a
bare `send --enter` presses Enter where the caller sits.

An unmarked id is deliberately left out of that promotion. #567 made the
address slot take bare ids, and `send 83 --key C-c` addressing pane 83 is
fine because `--key` says "press this" and nothing else. `--enter` does
not: `send 2 --enter` reads at least as much like typing 2 into your own
pane and running it, and turning it into a keystroke at pane 2 would be
the silent retarget #567 spent its diff closing. It stays a loud error,
now naming both spellings (`send %83 --enter`, `send %PANE 83 --enter`)
rather than only the typing one.

The reference, the bundled skill reference, `send --help` and the
`--enter` help all said the old thing in slightly different words; they
now say the same thing as each other and as the code.

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