Commit Graph
522 Commits
Author SHA1 Message Date
l0ng-ai fc01b3e31f fix(client): stop timeout setup from masking the daemon's refusal
On macOS, setsockopt against a socket whose peer has already closed fails with
EINVAL. The daemon answers a bad request by writing one Error frame and hanging
up at once, so PaneSession's `set_recv_timeout(...)?` would fail before the
refusal was ever read — turning "no such pane 42", already sitting in the
buffer, into "Invalid argument".

Bounding the reply wait is an optimisation, not a correctness requirement, so it
is now best effort in both attach/observe and spawn. Nothing can hang as a
result: a closed peer returns EOF immediately, and a live peer is exactly the
case where setsockopt succeeds.

This is what made client_lib's reattach test red.
2026-07-31 13:04:51 +08:00
l0ng-ai 400d7d76b6 fix(daemon): wake displaced controllers without polling, keep busy links usable
run_stream polled pane.controls(epoch) every 200ms behind a read timeout, so
every attached pane woke its thread five times a second just to notice a
handover that may never come. The writer already learns of the handover the
instant it happens — its channel closes — so it now shuts the connection's read
side down on its way out, and the reader goes back to a plain blocking read.

SshManager::routes() reported a link as disconnected whenever its slot's
try_lock failed, which is precisely when the link is in use. The CLI's
-m <machine> refuses to route over a link it is told is down, so an actively
used connection would intermittently fail. Busy now reads as connected, matching
how SshConnection::is_alive resolves the same contention.

Also: the exit-code probe takes the child lock with try_lock, since Drop holds
it across a blocking wait(), and its window drops from 2s to 500ms — it only
needs to cover the race between pty EOF and the child becoming reapable, and
everything past that is a pane that looks frozen to every client. Observer
budget now covers status traffic and the initial replay, not just output.
Uptime is anchored where the control listener opens so a GUI-hosted server
does not report itself as freshly started.
2026-07-31 13:04:33 +08:00
thomasandClaude Fable 5 794ae89d24 feat(cli)!: drop the attach verb and the design doc
The CLI's user is the coding agent; run/send/capture/events cover it.
Workspace-level ws attach/detach stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:36:25 +08:00
thomasandClaude Fable 5 05dffdf1f4 fix(cli): send rides the one-shot SendInput instead of a preempting attach
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:05:15 +08:00
thomasandClaude Fable 5 b047b4cc06 merge: CLI review fixes — CI coverage, kept-pane filing, endpoint honesty
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:03:24 +08:00
thomasandClaude Fable 5 2c3bf51ccb merge: daemon review fixes — SendInput, displaced-controller close, observer budget
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:55:32 +08:00
thomasandClaude Fable 5 acf6ee63d8 fix(daemon): one-shot SendInput, displaced controllers close, observers get a budget
ClientMsg::SendInput (kind 55) writes to a pane's PTY without touching the
controlling subscriber, its epoch, or the size, answered by DaemonMsg::InputAck
(kind 51) or an Error for a missing or exited pane; PaneClient::send_input
wraps it. Both stay within protocol 5.

run_stream now polls its half of the socket and epoch-checks against the pane
before forwarding Input or Resize, so a controller displaced by a preempting
Attach stops writing into the shell and has its connection shut down instead of
half-open forwarding forever.

Each observer meters its queued Output through its own OutputGate; one that
lets 8 MiB pile up is pruned rather than growing daemon memory, while the
controller and the PTY never wait on it.

The pidfile reap guard accepts any legitimate daemon exe name (current exe,
tty7-app, tty7-server, tty7; .exe optional, case-insensitive on Windows), and
the agent-hooks console fast path matches tty7-server.exe and tty7.exe next to
tty7-app.exe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:54:53 +08:00
thomasandClaude Fable 5 b46183688e fix(cli): review findings — CI coverage, kept-pane filing, endpoint and lifecycle honesty
- workspace: tty7-cli joins default-members, so a bare root cargo test runs it
- run --keep files the pane into its workspace via TabCreate (and refuses to
  keep a pane no workspace would list); --ws help says what it really does
- server start|stop|restart|logs refuse -m instead of silently acting locally
- server start kills the spawned process when it never opens its endpoints
- -m over a down link is refused instead of redialing with auto auth
- capture help tells the truth: raw ANSI bytes, last ring segment by default
- a missed exit-code probe exits 1 with a stderr note, not a fabricated code
- TTY7_SOCKET is honored: control dials it, the pane endpoint is its sibling
- attach's success JSON says attached, not detached_from
- e2e daemons ride a KILL_ON_JOB_CLOSE Job Object on Windows, so a hard-killed
  harness cannot leak servers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:51:41 +08:00
thomasandClaude Fable 5 b10a40a581 docs: attach is deferred — the CLI's primary user is the coding agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:31:59 +08:00
thomasandClaude Fable 5 866ebb6222 merge: real CLI backend — every non-interactive verb live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:19:19 +08:00
thomasandClaude Fable 5 fafb0019f4 feat(cli): real server backend — every non-interactive verb goes live
StubBackend is gone; RealBackend lazily opens a ControlClient for control
ops and a PaneClient for pane ops. Backend::spawn_shell now returns the
daemon-assigned pane id, so every creating verb (new, tab new, split)
spawns first and seeds the tree op with the real id — client-side pane-id
allocation is deleted. Live end to end: ls, ws/tab/pane verbs, new, send
(attach-input-detach), capture (observer replay; --scrollback = whole
ring, default = the ring's last segment), procs, run (streams output,
passes the child's exit code through, --keep leaves the pane), events
(human lines or NDJSON), agents, status, machine ls, and doctor's server
half (reachability, dialect, status, links). tty7 server start|stop|
restart|logs manage a sibling/PATH/TTY7_SERVER_EXE tty7-server. -m routes
both channels over the local server's link, resolved against Routes by
key or bare host; jump/proxy-chained keys are refused with the reason.
Interactive attach stays stubbed for the next slice. A harness-free e2e
suite drives the compiled tty7.exe against an isolated real server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:18:30 +08:00
thomasandClaude Fable 5 5511818af9 feat(core): pane exit codes, read-only observe, procs and routed pane clients
The daemon now waits its pane child and puts the real exit code on the
Exited frame (and replays it to late subscribers of a dead pane) — the
prerequisite for tty7 run's code passthrough. The client library gains
PaneClient::observe (read-only replay+stream), PaneClient::procs, and
PaneClient::routed for reaching a remote machine's pane daemon over the
local server's ROUTE frame, mirroring ControlClient::routed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:18:16 +08:00
thomasandClaude Fable 5 34350018ec fix(client): PaneClient::spawn carries the workspace for TTY7_WS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:50:21 +08:00
thomasandClaude Fable 5 db04919999 merge: tty7-cli crate skeleton
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:10 +08:00
thomasandClaude Fable 5 3285a93137 merge: rename the GUI binary to tty7-app
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:09 +08:00
thomasandClaude Fable 5 ee69a078f0 merge: tty7_core::client — public control and pane clients
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:01 +08:00
thomasandClaude Fable 5 f24a69de66 merge: server gaps — pane observers, TTY7_* env, aggregate queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:45:55 +08:00
thomasandClaude Fable 5 c8db432b63 fix(ui): one word per concept — shells, server, connection
The workspace switcher's row menu now uses the same verbs as every
other entry point (Stop/Delete Workspace), running processes are
shells everywhere, SSH links are connections, and the background
process is the server in all user-facing text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:45:46 +08:00
thomasandClaude Fable 5 753d1dceee feat(core): pane observers, TTY7_* context env, and control aggregate queries
Server-side gaps for the tty7 CLI (docs/cli-design.md, the additive tier):

- Pane multi-subscriber: the single controlling subscriber keeps its
  preemption semantics, and a pane now also carries N read-only observers.
  A new pane-protocol frame `Observe { pane_id, size }` (kind 54) joins a
  connection as an observer: it gets the Snapshot replay, then
  Output/Exited/Size, and its Input/Resize is answered with an Error frame
  instead of reaching the shell. Observers never displace the GUI, never
  defer the dead-pane reap, and are pruned when their connection goes away.

- Context env injection: every locally spawned shell now carries
  TTY7_PANE (its pane id), TTY7_WS (the workspace named in Spawn), and
  TTY7_SOCKET (the control endpoint path), next to the existing TTY7
  marker. ClientMsg::Spawn gains an optional `workspace` field, carried
  by the SPAWN_OWNED frame with serde defaults.

- Aggregate queries: ControlRequest::{AgentStates, Routes, Status} with
  ReplyOk::{AgentStates, Routes, Status}. AgentStates snapshots each
  pane's live agent session state through a new Services::panes directory
  wired from the daemon's registry; Routes lists the SshManager's held
  connections with key/kind/liveness; Status reports pid, uptime from a
  process-start instant, pane count, both dialect versions, build, and
  the control socket path.

- Dialect bump: CONTROL_VERSION 3 -> 4, PROTOCOL_VERSION 4 -> 5,
  following the convention set by bed22d8 and 1792bb8/a4972d3 where every
  additive variant bumped the strict-equality handshake versions (and
  with them the tty7-server-c{control}p{protocol} install name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:44:22 +08:00
thomasandClaude Fable 5 ec19525332 feat(core): extract the public client library tty7_core::client
ControlClient wraps the control-socket machinery behind connect /
connect_at / routed, adds a channel-backed events API on top of the
existing EventSink, and keeps request deadlines and blob replies.
PaneClient speaks the pane socket: one-shot list/version/kill plus
spawn/attach sessions that stream DaemonMsg and split into input and
output halves. transport grows connect_endpoint_at so a client can
reach a server by explicit endpoint file instead of the global config
dir. Integration tests drive both clients against a spawned
tty7-server --daemon in an isolated config/data dir on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:36:03 +08:00
thomasandClaude Fable 5 917d09f014 feat(cli): tty7-cli crate skeleton — grammar, addressing, backend seam, output
The thin console CLI from docs/cli-design.md, transport-free for now:

- clap grammar for the full documented surface: ws/tab/pane/machine/server
  nouns, the hot-path top-level verbs (ls/attach/run/new/split/send/capture/
  procs/agents/events/status/doctor), global -m/--json/-q, `run -- <cmd...>`,
  and bare `tty7 [path]` parsing as the GUI launcher stub.
- tmux-style addressing (%42 pane, @7 machine-wide tab ordinal or @<uuid>,
  workspace by name / id / unique id prefix) with implicit-context fallback
  to TTY7_PANE / TTY7_WS and a "not inside a tty7 shell" error naming the fix.
- Backend trait as the integration seam: control() speaks real
  tty7_core::daemon::control ControlRequest/ReplyOk values, plus declared
  pane-side entry points (spawn_shell/send_input/capture/procs/attach_pane/
  run/events). StubBackend fails loudly until the transport client lands;
  MockBackend asserts the exact request shapes every structural verb builds.
- Plain aligned tables and trees for ls/tree/pane ls, one JSON object per
  command under --json. Exit codes: 0 ok, 1 failed, 2 usage (clap default).

agents/status/machine ls stay stubbed: they need ControlRequest::AgentStates/
Status/Routes, which another slice is adding; ws stop and server start/stop
likewise wait on their mechanisms rather than inventing protocol.

Build/test this package alone (cargo build -p tty7-cli): its bin is named
tty7 and collides with the GUI bin until that one is renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:34:52 +08:00
thomasandClaude Fable 5 10b6741368 refactor: rename the GUI binary to tty7-app, freeing tty7 for the CLI
The package name and every display name ("tty7" in menus, tray, .desktop
Name, CFBundleName, installer AppName, shortcuts) stay as they were; only
the executable file is now tty7-app / tty7-app.exe, per docs/cli-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:28:00 +08:00
thomasandClaude Fable 5 9f88bf223b docs: tty7 CLI end-state design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:17:05 +08:00
66b238ee8f fix(input): copy and paste answer Ctrl+Shift+C/V, and Paste is rebindable (#271)
Off macOS every GUI terminal teaches the same chords -- GNOME Terminal,
Konsole, Windows Terminal and WezTerm all copy on Ctrl+Shift+C and paste on
Ctrl+Shift+V -- but tty7 answered neither. Both are now defaults, alongside
the unchanged Ctrl+C/Ctrl+V.

Shift+Insert was installed as a hardcoded binding in init(), which made it
invisible: Settings could not show it, record over it, or warn when another
chord collided with it, and a user's own shift-insert binding silently lost
to it in the Terminal context. Copy and Paste are now rows in the default
table like any other action. Shift+Insert rides along as Paste's second
chord through the same mechanism that gives InsertNewline its Alt+Enter --
generalized from a one-action special case into a table -- so rebinding
Paste retires both defaults together instead of leaving Shift+Insert
pasting behind the user's back.

The control-code guard also tightens: Ctrl+2..8 are NUL, ESC, FS, GS, RS,
US and DEL in xterm-style terminals, and Ctrl+` is NUL, so a future default
cannot land on them.

The changelog now also spells out the word-motion trade Alt+arrows made:
Alt+left/right focus panes (Windows Terminal's default), and word movement
in the prompt editor is Ctrl+left/right, with both Shift variants selecting
by word.

Refs #269

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:03:02 +08:00
ccc98b90a9 fix(input): stop the Windows/Linux keymap and CRLF pastes from breaking the shell (#270)
* fix(input): stop the Windows/Linux keymap and CRLF pastes from breaking the shell

Two problems a Windows user hit within an hour of installing tty7, both from
code that only ever had macOS in mind.

A bracketed paste forwarded the clipboard byte for byte. Text copied on Windows
carries \r\n, and vim counts CR and LF as two line breaks, so pasting code into
it left a blank line under every line. Fold \r\n to \n inside the paste markers
so a Windows clipboard produces exactly what the same paste already produced on
Linux and macOS. The non-bracketed path keeps converting to CR: with no paste
mode to tell text from typing, a line break has to arrive as the byte Return
sends.

`secondary` means Cmd on macOS and Ctrl everywhere else, and the default keymap
was carried over from macOS unchanged, which put window actions on top of
terminal control codes. Ctrl+D could not send EOF, Ctrl+[ could not send ESC,
and Ctrl+W, Ctrl+K, Ctrl+P, Ctrl+J, Ctrl+T, Ctrl+Q and Ctrl+S were all taken.
These bindings carry no context, so they match before the terminal sees the key
-- apply_readline_ctrl already sends EOF for ^D, it was just unreachable.

Off macOS, ctrl-<letter> and ctrl-[ ] \ space now belong to the terminal and
window actions move to ctrl-shift-*, the convention GNOME Terminal, Konsole,
Windows Terminal and WezTerm share. Tabs move to Alt+1-9 and pane focus to
Alt+arrow, since Ctrl+Alt+arrow is workspace switching under GNOME. Ctrl+S
keeps saving in the code panel but propagates when the editor is unfocused, so
a shell still gets XOFF. Shift+Insert pastes. macOS is untouched, and a user's
own rebindings still win over the defaults.

A test walks the default table and rejects any chord that lands on a control
code, so the next binding added cannot quietly bring this back; a second one
rejects two actions claiming the same chord, which is how the reshuffle caught
NewTab colliding with ReopenClosedTab.

Refs #269

* fix(history): keep one history store per machine, not one per install

tty7 owns ^R at the prompt and shows its own fuzzy menu, but `history.rs` had
no notion of where a command had run: `load()` read one file and `append()`
wrote one file, so an ssh session was offered the commands typed on the local
machine. That is worse than an empty menu -- the entries look plausible until
one of them runs against the wrong box.

History is now scoped. The local store keeps its existing path, so nobody loses
what they have; each remote gets its own file under `history.d/`, named after
the target with a hash appended so a label that is not a filename ("me@box:/srv")
cannot escape the directory or collide with another host.

A pane picks its scope from the innermost remote it can see -- the ssh target
reported by shell integration, else its host id -- and follows it when that
changes, clearing the menu and reloading in the background rather than showing
the previous machine's entries while it waits. A reload that lands after the
pane has moved on again is dropped.

On a remote workspace, where a Host impl exists, the far end's own
~/.zsh_history and ~/.bash_history are read through the same channel git status
and file listings already use, so the first ^R on a freshly connected box has
something in it. Bare ssh has no Host to read through and falls back to what
tty7 recorded itself for that target, which is still the right machine's
commands.

Refs #269

* fix(test): the control-code guard must admit Ctrl+S, which falls through

The guard forbade every bare ctrl-letter default, but EditorSave staying on
Ctrl+S is the design: its handler propagates whenever the editor does not
have focus, so the terminal still receives XOFF. The guard's real invariant
is "no default may swallow a control code" -- an action that falls through
does not swallow. EditorSave is exempted by name, with the condition for
earning a spot written next to the list.

The test is vacuous on macOS (secondary parses to Cmd), which is why the
contradiction only surfaced on the Linux runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(history): never seed an inner ssh target from the workspace host's files

remote_shell_history_sources gated only on "scope is remote and host is
remote", so a pane that ssh'ed onward from a remote workspace -- scoped to
the inner target -- was seeded from the workspace host's ~/.zsh_history:
another machine's commands in the menu, the exact confusion scoping exists
to prevent. The Host can only reach the workspace machine's home, so seeding
is now reserved for the host's own scope; a pane with a remote_context falls
back to what tty7 recorded for that target, exactly like bare ssh from a
local pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:11:07 +08:00
l0ng-aiandl0ng-ai 8c1946d763 chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked
.rs files with `uncomment` 3.5.1. It parses each file with tree-sitter
instead of matching text, so comment-like content inside string literals
is left alone: the JavaScript plugin source embedded in agent_hooks.rs
raw strings keeps its own `//` lines.

Left alone: Cargo.toml comments and the shell scripts under scripts/.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:36:15 +08:00
l0ng-aiandl0ng-ai ac8968643d fix: reattach the last workspace closed, and resupervise a reopened remote one (#267)
* fix(session): come back to the last workspace closed, not the home page

Launch only ever restored a workspace that still had a window at quit, so
closing them one by one and relaunching came up on the empty home page —
with no hint that four workspaces were sitting there detached.

Closing a window here is a detach: its panes keep running in the daemon,
which makes that workspace every bit as much "where you left off" as one
that still had a window on screen. `workspace_to_restore` now falls back
to the most recently active workspace of any kind, and since
`close_window` touches it on the way out, that is the one closed last. An
open workspace still outranks a more recently touched detached one, so a
background agent cannot steal the restore from the window that was
actually on screen. Deleting a workspace still drops it from the file —
that is the one gesture meaning "done with this".

`None` therefore means a genuine first run only, which retires the
`FreshStart::HomePage` launch path along with the enum that threaded it
through `windows::open` and `Tty7App::for_workspace`.

* fix(remote): reopening a remote workspace starts the supervisor again

The supervisor stops — and clears every MachineLink with it — as soon as
no open workspace is on a remote machine, which closing the last remote
window does. The connection itself stays: a closed window is a detach,
so HostLinks keeps the socket for whatever opens next.

Reopening that workspace then found a live connection with no link
behind it. `reopen_remote_at_startup` read the surviving HostLinks entry
as "another window got there first" and returned before starting the
pump, so nothing ever put a MachineLink back and `status_of` answered
Disconnected for good: a "Not connected to <machine>" strip and a dead
keyboard over panes that were visibly still running on the far side.

An existing HostLinks entry is not a reason to skip the supervisor. It
answers "is there a socket"; what the window renders from is `machines`.
Both now go through RemoteLinks::supervise, which is a no-op for a local
workspace and an unconditional ensure_running for a remote one —
idempotent, so a machine already supervised costs a flag check, and the
first tick over a live socket marks it Attached without opening a second
SSH session.

Fixes the same hole on the switcher's path, which never told the
supervisor anything at all: picking a remote workspace from it goes
through `switch_workspace`, not the launch path.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:28:44 +08:00
1e5106de59 fix(install): every fixture installer gets the fixture's dialect (#266)
`an_install_reports_both_transfers_to_completion` and
`every_report_carries_the_host` built their `Installer` by hand — `with_version`
and `with_timeouts`, no `with_dialect`. So they ran at
`RemoteProtocol::of_this_build()` while `FakeRemote` answered the `--protocol`
probe with the fixture's fixed `c3p4`, and passed only because the real
`CONTROL_VERSION` currently *is* 3.

Found by bumping `CONTROL_VERSION` to 4 locally: both tests fail with
`DialectMismatch { wanted: c4p4, spoke: c3p4 }` — an error about a fake's
plumbing, raised in two tests that are about *progress reporting*, at the one
moment somebody is busy changing the wire and least able to spare the
attention. The fixture's own comment says the dialect is a fixed literal
"rather than `RemoteProtocol::of_this_build`", which is exactly right and was
exactly what these two bypassed.

Both now go through `installer()`, whose `release` parameter is a
`&dyn AssetFetcher` so that a chunked fetcher — the only reason either test
hand-rolled the builder — is no longer a reason to leave the helper. The
helper's doc says what forgetting it costs, since that is the failure mode a
reader needs and the compiler cannot give.

No behaviour change, and the same 644 tests before and after.

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:15:41 +08:00
2a4b5c7f68 fix(release): name the server assets for whoever downloads them, not for cargo (#265)
`tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both
workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build
triple went straight into a published filename — and the triple's *vendor*
field, for a Linux target with no particular vendor, is the literal word
`unknown`. It has been sitting on the releases page reading like a failed
lookup.

Of the triple's four fields only two say anything to whoever downloads this:
the architecture, which is what `asset_for_uname` picks by, and `musl`, which
is why one file runs on any distribution. So:

    tty7-server-x86_64-unknown-linux-musl  →  tty7-server-linux-x86_64-musl
    tty7-server-aarch64-unknown-linux-musl →  tty7-server-linux-aarch64-musl

`<os>-<arch>` in that order because that is what the GUI assets in the same
release already use (`tty7-<version>-linux-x86_64.tar.gz`). One release should
be one naming scheme; it was two.

The triple stays everywhere it really is a build target — `cargo zigbuild
--target`, the `target/<triple>/release` path, the rust-cache key, ci.yml's
matrix. The workflows now carry both: `target` for the build, `asset` for the
filename, deliberately not the same string.

This name is a contract with more than the release step, and all of it moves
together:

- `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client
  appends to a release URL.
- `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks
  for `<dir>/<asset name>` with nothing translating, so the *filename* is as
  much a contract as the `server/` directory is — now said out loud in both
  places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a
  cross-compile has to be copied to the asset name, not left as `tty7-server`.
- The GUI's install prompt fixture, the checksum manifest fixtures, and the
  `MissingBundled` assertions.

Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s
`find`, and the installer's `server\*` are all name-agnostic.

A new test pins both names as literals — the module header already says this
naming is "a *literal* contract with the release workflow", and asserting the
consts against themselves asserted nothing. It also fails on the substring
`unknown`, since that word only ever arrived here by way of `matrix.target`,
and checks neither name contains the other, which is what
`checksums::expected_digest` says out loud that it relies on.

Compatibility: a stable client asks its own frozen tag, which keeps whichever
name it shipped with, so every released client keeps working. The rolling
`nightly` tag is replaced each night and its prune step drops assets the run
did not upload — so an *already installed* nightly client 404s on the server
download until it updates itself. Accepted deliberately; the next release is
what has to be right.

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:55:52 +08:00
66fcc95e2e fix(install): name remote servers by dialect, and prove the name before publishing (#264)
* fix(install): name remote servers by dialect, and prove the name before publishing

A remote tty7-server was installed as `tty7-server-<version>`, and one stat of
that path decided whether anything got installed at all. The version string is
not an identity: every build between releases carries the same one, so a client
speaking control v3 found the released v2 binary sitting at the exact path it
looks for, skipped the install, adopted a server it cannot talk to, and died in
the handshake with no way out. `check_running_build` compared the same string
and so never even probed.

Name the file after the two numbers that actually decide compatibility —
`tty7-server-c<control>p<protocol>` — and after uploading, ask the binary what
it speaks and publish it only if the answer matches the name it is about to get.
That turns "the filename is the dialect" from a convention into a fact a stale
bundle or an older release tag cannot break, and surfaces an architecture
mismatch at install time rather than as `Exec format error` inside a later
daemon launch.

The keep-or-restart prompt offered "Keep Sessions" for a state where keeping
means the connect fails a moment later; it now offers Cancel. A handshake that
does fail gets a Restart Server button, gated on that one error so no other
connect failure can reach a destructive action that would not fix it.

* fix(remote): offer "Restart Server" only where there is one, and keep a torn-down pane's resume record

`RemoteTarget::is_ssh` answers the question the verb depends on, in one place
rather than re-spelled at each call site: a WSL distribution's server is started
by this client and a `LocalStdio` machine is a child process per connection, so
neither has a long-lived daemon to stop and start — which is exactly why
`router::restart_server` refuses them. The UI that offers the action and the
router that carries it out can no longer disagree about who has one.

"Restart Daemon…" now asks the window which daemon serves it. Run from a remote
window it used to end every local session in every other window and leave the
machine in front of the user untouched — a destructive button that did nothing
its label promised.

A pane torn down with its coding agent no longer publishes an observation. The
kill hangs up the whole process group, so the agent dies before the PTY EOFs and
a poll firing on the bytes still in flight sees nothing recognizable; publishing
that cleared the record's agent and session id, and the reopened workspace came
back to a bare shell instead of the conversation. The same answer about a pane
nobody is tearing down still means the agent exited on its own.

* fix(remote): review follow-ups on dialect-named servers

Six findings from a review pass over the two commits. One commit
because they are all corrections to the same change.

- The progress bar the `Restarting` phase was added for never appeared
  for the flow that needs it most. The switcher only reads a machine's
  phase while *this* window is connecting or showing an error, and
  "Restart Server…" in the machine's `⋯` menu is deliberately offered
  whatever the link is doing — so restarting a connected or offline
  machine recorded a phase nobody read, and the click had no visible
  effect for the length of two timeouts. A `Restarting` phase now
  counts on its own: it is only ever recorded by an action a user
  asked for, it ends every session on that machine including the ones
  other windows are showing, and it is the one flow that transfers
  nothing and so has no other way to say the click landed. The header
  says "restarting…" rather than borrowing "installing…", which
  described bytes that are not moving.

- "Restart Server" on the error card silently did nothing when the
  machine could no longer be addressed: `replace_remote_server` logged
  the failure and returned, past a prompt that had just promised the
  server would be replaced. It reports it, like every other failure on
  that path.

- `is_ssh` gates an action that ends every session on a machine, and
  spelled the three yes-variants as a `matches!` — so a new
  `RemoteTarget` would inherit "not SSH" by falling off the end of the
  pattern, which is the opposite of what its own test claims. An
  exhaustive `match` makes the compiler ask.

- The mismatch prompt's detail explains its buttons by name and the
  buttons were written out again at the prompt. `Keep Sessions` was
  one of them until this branch removed it, which is exactly the drift
  worth preventing twice: `MISMATCH_ANSWERS` is named once beside the
  detail, and a test asserts the detail explains every answer offered.

- `unique_temp`'s pid is private to a process, not to a client: two
  clients on different machines can share a pid. Left alone, because
  the new `--protocol` check stands behind it — interleaved bytes
  cannot answer with our dialect, so they are refused rather than
  published — but said, along with the fact that two installs inside
  one process share the path too and are the locks' job, not this
  function's.

- Ten doc sites still said the installed file is named after the
  version, including the module table's step 2, `client_version`'s
  "the server that matches me", `Installer::run`'s postcondition and
  `ensure_remote_server`'s returned path. Also corrected the claim
  that WSL and `LocalStdio` have no daemon to restart: WSL's is
  started by this client, which is why "stop it and reconnect" is the
  whole verb — the reason the router refuses them, not the absence of
  a daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:54:40 +08:00
l0ng-aiandl0ng-ai 33eedad90a fix(ui): the routed-auth test no longer hangs, and no longer loses its prompt (#263)
* fix(ui): the routed-auth test can no longer hang the whole suite

`a_routed_auth_prompt_carries_the_machine_that_raised_it` waited for its own
prompt in an unbounded spin loop. `AUTH_MAILBOX` is process-global and
`pump_auth_sheets` drains every entry in one pass, so any gpui test in this
binary that drives a tick can take that prompt first — and unbounded, the loop
then spins until GitHub's six-hour job limit.

This is the hang CI has been paying for, named twice and on two platforms:
2h50m inside this test on x86_64-unknown-linux-gnu (run 30517182773), and again
on windows-msvc (run 30526538997). It is *not* the cfg(windows) transport
accepts bounded in #261; those were a separate latent six-hour hang.

The loop now has a ten-second deadline and an assertion that says what an empty
mailbox means and whether the responder thread had finished. Note what that does
and does not buy: a stolen prompt becomes a fast, self-explaining failure instead
of a six-hour outage, but the theft itself is still possible, and curing it means
deciding what that process-global mailbox should be under test — a design call,
not something to settle inside a CI fix.

ci.yml keeps only a comment where a post-mortem step used to be, because the
step was worthless twice over. It cannot work: GitHub kills the step's process
tree when `timeout-minutes` trips, before the next step runs, so on run
30526538997 the dump printed two headers and nothing between them. And it is not
needed: libtest already prints "<test> has been running for over 60 seconds",
which was in every hung run all along. The obstacle was only ever that a job's
log cannot be fetched while the job is in progress — which the `Test` timeout
fixes by making the step fail.

* fix(ui): a test waiting on the auth mailbox is no longer raced by a tick

The previous commit made the flake loud instead of fatal; this stops it
happening. CI proved the mechanism on the very next run: the new assertion
fired on windows-msvc with "no routed prompt arrived within 10s ... Responder
thread finished: false", 731 other tests passing, the whole suite done in
12.39s instead of hanging for six hours.

`AUTH_MAILBOX` is process-global and `pump_auth_sheets` takes every entry in
one pass. That is right for the app — one tick, one mailbox — and wrong in a
test binary, where a test waiting for the prompt it just caused shares that
mailbox with every gpui test that drives a tick. The tick drains a prompt it
has no idea was spoken for, and the waiting test never sees it.

`MAILBOX_TURN` arbitrates: a test that needs its own prompt back claims it for
the exchange, and the drain yields while it is held. Both the static and the
claim in `pump_auth_sheets` are `#[cfg(test)]`, so a release build is byte-for
-byte what it was — there is one app, one tick, and nothing to arbitrate.

The compromise is visible and deliberate: test-only synchronisation inside a
production function. The alternative that needs no such thing is to stop the
mailbox being process-global — dependency-injected per app — which is a larger
change to a path this defect does not otherwise justify touching.

No deadlock: the claim is the first thing `pump_auth_sheets` does, before it
locks the mailbox, so the two locks are only ever taken in one order.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 18:08:56 +08:00
l0ng-aiandl0ng-ai 4f37fa8eb8 fix(transport): bound the Windows test hang — no unbounded loopback accept, and CI timeouts (#261)
* ci: bound the Windows test hang and stop superseded runs holding slots

The Windows `Test` step intermittently hangs — roughly one run in ten, on
any branch, while the same commit passes on a re-run. `cargo test` has no
timeout of its own, so every occurrence ran to GitHub's six-hour job limit:
three times in one day a 75-second step held a runner slot for hours and
reported nothing about which test was stuck. One of them took the runner
down with it ("the hosted runner lost communication with the server"), and
while those zombies held slots an unrelated PR's macOS job queued for two
and a half hours.

Three changes, none of which fix the hang itself:

- `timeout-minutes` on the `Test` step (20) and `Build` step (30), plus a
  60-minute job backstop. The honest budget is ~75s warm and ~3.5 min when
  the step also compiles the test targets, so a trip means a hang.
- A Windows-only post-mortem step on failure that dumps the process table.
  libtest names a test when it *finishes*, so the hung one is the name
  missing from a truncated list; the surviving test binary names its crate
  and test target instead.
- `concurrency` with `cancel-in-progress` for pull requests, so a
  superseded run stops competing for the shared concurrent-job budget.
  Pushes to main are exempt: each commit's run is the record of whether
  that commit was green.

The hang's cause is still unknown and cannot be reproduced off a Windows
runner. This makes it report in 20 minutes instead of costing six hours.

* fix(transport): no Windows test may block forever on a loopback accept

The three `cfg(windows)` tests in the transport's test module held five
unbounded `listener.accept().unwrap()` calls, each paired with a client
thread that `unwrap()`s its `connect`. When such a thread panics — a
transient loopback refusal on a loaded runner is enough — nothing is left
to wake the accept, and nothing is left to feed the handshake read after
it. The test does not fail; the whole test binary stops.

That is the shape of the hang CI has been paying for: Windows-only (these
tests are `cfg(windows)`, so no developer's macOS run executes them),
intermittent, and mute — libtest names a test only once it *finishes*, so
no log ever said which one was stuck.

`accept_within` polls a non-blocking listener against a ten-second
deadline, then restores blocking mode and puts a read timeout on the
accepted socket. Winsock hands an accepted socket the listener's blocking
mode, so clearing it on the returned stream is a real step, not a no-op.
Verified on the host target, where the logic is identical std code: a real
client is still accepted and its handshake read still works, and a client
that never arrives fails in 10.0s instead of never.

Whether this is the exact hang CI hit is unproven — it cannot be
reproduced off a Windows runner, and `aws-lc-sys` will not even build for
the Windows target on a mac. It is the only Windows-only cluster of
unbounded network waits in the tree, and it matches every observed
symptom. Either way the six-hour failure mode is gone from here.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 16:23:59 +08:00
l0ng-ai 587887a55a fix(ui): a failed WSL probe keeps the distributions it already found 2026-07-30 13:38:22 +08:00
l0ng-ai 1eacbdcfca fix(wsl): wait for the daemon to answer instead of guessing a second 2026-07-30 13:38:22 +08:00
l0ng-ai 14ca31fb91 docs(core): retire the M8 notes on a WSL target that now exists 2026-07-30 13:38:22 +08:00
l0ng-ai 02d7482b68 docs(changelog): reference the WSL workspaces PR 2026-07-30 13:38:22 +08:00
l0ng-ai 30e3f5be53 feat(remote): offer this computer's WSL distributions as machines
The WSL transport, installer, router target and protocol kind have all
been in place since remote workspaces landed, but nothing constructed a
`RemoteTarget::Wsl` outside tests — `available_hosts` returned saved SSH
profiles, `~/.ssh/config` aliases and a dev-only `--stdio` row, so the
one machine that needs no configuration at all was the one you could not
pick.

Every installed distribution is now a row in the switcher, between the
saved profiles and the config aliases: installing one is as deliberate
as writing a profile, but the list is discovered rather than written, so
it does not outrank the machines named by hand. The label is the exact
string `wsl -d` takes — that string is also the connection key — and the
endpoint column says `WSL`, which is what makes searching for `wsl` find
them.

`core::shells::wsl_distros` is promoted out of `cfg(test)` and answers
empty off Windows, so the new-tab dropdown and the switcher enumerate
distributions through one function and cannot disagree about which
exist. It spawns `wsl.exe`, and `available_hosts` runs inside the
switcher's render, so the list is a global filled by a backgrounded,
TTL'd `sweep_wsl` — the shape `terminal::pane_liveness` already uses for
the machine answers drawn two rows above these. Swept at startup and on
every open, so `wsl --install` is visible after reopening the panel
rather than after a restart.

`spec_for`'s WSL arm said "not available in this build yet"; it now says
there is no SSH connection to describe, which is what the `Err` has
always meant to the routed paths that read the target instead. And the
band is "Other Machines" rather than "Other SSH Hosts", since a
distribution reached by spawning `wsl.exe` is sitting in it.
2026-07-30 13:38:22 +08:00
l0ng-ai b6577d10a6 fix(wsl): send a pane to the remote's pane socket
A remote `tty7-server` listens twice and the two dialects are not
interchangeable; which socket a routed stream lands on is decided by
`--pane` on the bridge command. The SSH path adds it via
`RouteChannel::bridge_command`, and `LocalStdio` adds it in the client's
`PaneWorkspace::route_header` because its argv is run verbatim — but
`RemoteLink::wsl` always built `[binary, "--stdio"]` and dropped
`setup.channel` on the floor.

So a WSL pane reached the control socket, wrote its `Spawn`, and was
answered with nothing: `routed connection closed after 56 up / 0 down
bytes`. The workspace connected, the window opened, and the pane inside
it said it could not reach the machine. `PaneWorkspace::route_header`
already documents this exact failure shape; WSL was the transport that
never got wired to it.

WSL is the one transport that builds its own argv, so the choice belongs
where the argv is built. `wsl_shell` routes its override through
`bridge_command`, the same rewrite SSH applies to its shell command, so
an escape hatch cannot silently lose the dialect.

The argv is now a pure `wsl_link_argv`, because the difference between
the two is one flag that cannot be checked anywhere a distribution is
required.
2026-07-30 13:38:22 +08:00
l0ng-ai a45830ae80 fix(wsl): hold the launch open until the daemon has detached
WSL reaps what an interop session started the moment that session's
`wsl.exe` exits, and `setsid` does not make the child safe the instant it
runs. The launch line backgrounds `tty7-server --daemon` and returns in
milliseconds, so the sequence was: `sh` exits, `wsl.exe` exits, WSL tears
the session down, and the daemon died before it could bind its socket.

What that looked like was `ensure_daemon`'s "started but nothing was
answering on the control socket after 15s", over a binary that had just
been installed correctly — no other symptom, and nothing in the launch
line to blame.

Reproduced against Ubuntu-24.04 by running the launch line alone: with
nothing after it the daemon is gone; with as little as `sleep 0.3` after
it the daemon lives and serves. `nohup`, `setsid --fork` and a
double-forked subshell all die the same way, so the wait is the fix
rather than a different spelling of the detach.

Deliberately not a wait *for the socket*: `ensure_daemon` only reaches
the launch when the socket did not answer, and the ordinary reason for
that is a socket file a dead daemon left behind (`wsl --shutdown` is
routine), so a `-S` test would pass on the stale file and change
nothing. The SSH path needs none of this, so the shared `launch_command`
is untouched and the wait lives in the WSL ops.
2026-07-30 13:38:22 +08:00
f6fa34a016 feat(ui): make the sidebar diff preview optional and bound its cost on large working trees (#247)
* feat(diff): make the sidebar diff preview optional and bound its cost

Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big
tree that could stall the window, and not everyone wants an in-app diff viewer
in the first place.

Two halves, matching the report.

The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default,
persisted in `config.json`). Off, the branch and the counts stay exactly where
they are and read exactly the same; they lose only the pointer cursor and the
`toggle_diff_overlay` handler, so the press falls through to ordinary tab
activation. Both come off one value — `diff_click_cwd` — so they cannot get out
of step.

The performance work. All five of the reporter's hypotheses held up against
v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff
(release, macOS arm64):

1. The full diff was buffered before parsing — `git_status::git` uses
   `Command::output()`. Now streamed line by line through the new
   `git_status::git_lines` into an incremental `DiffParser`: peak transient
   buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms →
   6.76 ms) on the background thread, where it never touches a frame.

2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the
   UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder.

3. The element tree is not virtualized — confirmed, not cured. Rendering is not
   being redesigned here; instead the element count is bounded (see 4) and
   `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line
   for the tail.

4. Auto-collapse was per file, and counted only +/− while the rendered body
   also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained*
   lines: sixty forty-line files, none individually large, went from 2400
   side-by-side rows to zero, under a summary saying the diff is too large to
   render efficiently and pointing at expanding individual files or `git diff`.

5. The Changes panel probed independently and kept its own snapshot. Both now
   go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one
   `Arc` into every watcher; opening the overlay while the panel already shows
   that repo now paints from the panel's snapshot instead of re-probing.

Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`):
90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals
deliberately escape every cap — they are compared against `--numstat` to detect
staleness, so a capped total would disagree forever and re-probe in a loop.

Small diffs are untouched: a forty-file, twelve-lines-each tree is not
oversized and still opens expanded, asserted directly.

Not verified: anything requiring the GUI. No frame timings, no visual check of
the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a
judgement call anchored on row count rather than a measured frame budget.

Refs #239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(diff): bound the untracked list and stop a moving default flipping toggles

Five findings from the review of the previous commit, all confirmed against the
source before acting.

The untracked list escaped every bound the previous commit added. `git ls-files
--others` reports the whole tree of anything not yet ignored, so a fresh clone
before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens
of thousands of paths — read through the buffering helper, retained without a
cap, ignored by `oversized()`, and rendered one non-virtualized row each. That
reaches the overlay without going through the diff at all, which is why the diff
budget never saw it. It is now streamed through `git_lines`, capped at
`MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most
`MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays
the true total via `untracked_total` — same split the diff side already makes
between what is retained and what is counted, because a count that shrank with
the budget would read as files having disappeared.

The overlay's expand state was an inversion set — "files flipped away from their
default" — which was correct while the default was per-file and stable. The
repo-wide `collapse_all` moves the default for every file at once, so any
refresh crossing the oversized threshold inverted every explicit choice
simultaneously: the two files the user had opened snapped shut and the rest
sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered
before the default is even computed, so nothing about the snapshot can reach it.
Covered by a test that asserts an explicitly opened and an explicitly closed
file both survive a transition in either direction while an untouched file
follows the default.

The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only
wrote `right_panel.diff` when the panel was the one waiting, so a probe the
overlay started was discarded even when the panel sat on that exact repo — the
overlay rendered the new snapshot while the panel kept the old one, same window,
same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed
separately: with probes deduped per cwd there is at most one in flight, so there
is no out-of-order overwrite to guard against.

The oversized banner reported `retained_lines()` as "diff lines", which after
the budget fires is what was kept, not what changed — it read "20000 diff lines"
directly under a header showing the exact +90000/-0. It now states
loaded-of-total and names which cap ate the difference, and lists each axis that
tripped the threshold so a big untracked list never reads as a claim that the
diff is big.

And the Changes panel deep-copied every untracked path String on every frame, on
the UI thread, for two `len()`/`is_empty()` reads — the same cost class the
`Arc` switch removed from the probe path.

995 tests pass, fmt clean, no new clippy warnings. The app was not built,
launched, or driven; visual acceptance is the owner's.

Refs #239.

* no-mistakes(review): cap Changes panel rows and fix oversized banner truncation notice

* no-mistakes(review): drive banner per-file truncation off parser flags

* no-mistakes(document): correct changelog cost count and overlay-trigger docs

* fix(diff): reconcile the diff-overlay work with the host-aware git refactor

The rebase onto main lands this change on top of the remote-workspace work
(#235, #242), which moved the git helpers into `tty7-core` and made every read
take the pane's `Host`. Adapting rather than papering over:

- `sidebar_diff_preview` moves to the core `Config`, where the struct now lives.
- The diff and untracked reads go through `git_status::git(host, cwd, ..)`.
- The shared probe, its in-flight set, and `install_diff_snapshot` key on
  (`HostId`, `PathBuf`) — the same path on two machines is two work trees — as
  does the Changes panel's `diff_pending`.
- `diff_click_cwd` became generic over what identifies a repo, so the setting
  gate did not need to learn about hosts.
- Main's newer card rounding reads `truncated`, which is an `Option` here now.

The streaming diff read is deliberately absent at this commit: `Host::git` is
buffered, so it is restored on top of a streaming host API in the next one.

Refs #239.

* feat(host): stream git reads whose size scales with the work tree

`Host::git` returns a fully-buffered `Output`, and the remote implementation
round-trips one over the wire. That is the right shape for the reads tty7 does
constantly — `rev-parse`, `symbolic-ref`, `--numstat` — all of which answer in
bytes. It is the wrong shape for `git diff HEAD`, whose output scales with the
work tree rather than with anything the UI can show: this repository's own
`git log -p -n 400` is 8.3 MB, and the diff overlay keeps a small fraction of it.

So `Host` grows a second entry point rather than changing the first. `git_lines`
delivers the same invocation a line at a time, and its **default implementation
buffers** — every host gets it for free, nothing that works today changes shape,
and it stays inside the "every git read funnels through the host" invariant
instead of becoming a way around it. Overriding it is an optimisation, never a
behaviour change: a test asserts the streamed and buffered reads yield identical
lines.

The local host reads straight off the pipe. The remote host adds
`ControlRequest::GitStream`, answered with `ControlEvent::GitChunk` pushes and a
terminating `GitEnd` carrying the exit status — the shape `WatchOpen` already
proved out.

Two protocol details worth the reader's attention:

The **client** picks the stream id, which is why the reply carries none. Ids
only need to be unique within a connection and a connection has one client, so
choosing it client-side lets the receiver be registered *before* the request
goes out. A server-assigned id arrives in the reply, leaving a window where a
chunk that overtook it reaches a client with no entry for that id and is dropped
under the unknown-id rule — silently losing the front of the diff. `WatchOpen`
needs a whole deferred-start mechanism to close that window; this does not.

The feature is **advertised and checked**, not assumed. A server predating
`GitStream` cannot decode the variant, and an undecodable frame ends the
connection — so sending it blind would not degrade, it would disconnect. Servers
advertise `git-stream`; a client that does not see it uses the buffered `Git`,
which is the path every remote pane used before this existed. Covered by a test
against a peer advertising only `control` and `host-rpc`.

Chunks carry newline-terminated line data batched to ~64 KiB, not verbatim
slices of stdout: the server reads through `git_lines` itself, so line content
survives exactly while `\r\n` and a missing final terminator are normalised
away. The only consumer is line-oriented. Framing per batch rather than per line
is what keeps a 90 000-line diff from becoming 90 000 frames.

Re-measured on the rebased code, against real git output, release build
(the previous figure was taken before the host refactor and no longer holds):

  buffered: 8 269 409 bytes resident, read 817 ms + parse 12 ms
  streamed: peak transient chunk 64 KiB, read+parse 557 ms

Lower peak memory *and* faster end to end — parsing now overlaps with git
producing output instead of waiting for all of it. The earlier synthetic
measurement showed streaming costing ~1.7x CPU; that was an artifact of reading
a warm page-cached file, where there was nothing to overlap with.

The app was not built, launched, or driven; visual acceptance is the owner's.

Refs #239.

* fix(host): make unsubscribing a watch take effect at the drop, not after it

CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created
*after* the subscription was dropped still reached a consumer holding a clone of
the receiver.

Tearing the watcher down is not instantaneous. The OS backend runs its own
thread, and on Windows a `ReadDirectoryChangesW` completion can fire during
teardown, reach the event closure while `raw_tx` is still alive, and be
forwarded by a coalescer that has not yet noticed the disconnect. So "dropped"
meant "stops delivering shortly", which is not what the subscription promises —
and for a remote host it is the difference between releasing a server-side watch
and leaking one.

The handle now closes the delivery channel in its own `Drop`, before any of that
unwinds. Batches already queued stay readable — `close` stops sends, not
receives — which is the one thing a consumer racing its own drop may legitimately
still see, and exactly what the conformance test allows for.

Not this branch's bug: the change here is to git reads, not watches. But main is
flaky in the same family — it failed the sibling `watch_coalesces_within_window`
eleven hours ago and was hardened for that one — so this fixes the cause rather
than loosening the test.

Refs #239.

* refactor(host): make the streaming git read part of the protocol

Remote workspaces have never shipped a release, so there is no deployed server
to negotiate with. The `git-stream` feature flag, the `has_feature` check and
the buffered fallback behind it were all guarding against a peer that cannot
exist — dead code that would have to be maintained, and read by the next person
as evidence that older servers are out there.

`ControlRequest::GitStream` is simply part of the control protocol now. Buffered
`Host::git` stays exactly as it was, for the many reads that answer in bytes and
have no reason to stream.

The remote test that proved the fallback becomes one that proves the stream:
the peer serves `GitStream`, splits a line across two chunks, and the client
reassembles it — the case the reassembly exists for.

Refs #239.

* no-mistakes(review): fix remote git-stream deadlock, chunk encoding and stray docs

* no-mistakes(review): stop git-stream batch growing after a send failure

* no-mistakes(document): note git-stream protocol delta in remote-workspace contract doc

* fix(host): bound a git stream's wait, its lines, and its concurrency

Three ways the streaming git read could still hold or hang more than it
should, all found reviewing #239's implementation.

A stream is answered by pushes, so neither of the failure paths the rest
of the client relies on covers it: the request deadline was satisfied by
the immediate `Unit` reply, and keepalive watches the link, which stays
up while a server-side git wedges on a network filesystem. The reader
parked forever, on one of a small pool of blocking threads, and the diff
probe it belonged to never released its per-repo claim — so that
repository's overlay and Changes panel were stuck on "Loading…" for the
life of the process. `git_lines` now waits `GIT_STREAM_IDLE_TIMEOUT`
between chunks. Between, not across: a slow-but-alive read must be
allowed to take as long as it takes, which is why a total deadline would
be the wrong instrument. Draining moved to `drain_git_stream` so all
three exits are reachable from a test without waiting out two minutes.

"Incremental" bounded the number of allocations but not the size of any
one of them: a line is only complete at its newline, so a work tree with
a minified bundle rebuilt the whole-output peak inside `LineSplitter`,
on both ends of a remote link and in the server's outgoing batch. Lines
are now capped at `MAX_LINE`, and what is cut says so in the line itself
rather than silently shortening a rendered diff. That also bounds the
server batch, which makes `GIT_STREAM_CHUNK_MAX` a frame backstop rather
than the only thing standing between a bundle and a 32 MiB payload.

Finally, `GitStream` is the one request that spawns a thread outside the
bounded worker pool, so nothing counted them.  `MAX_CONCURRENT_GIT_STREAMS`
per connection now does, with the slot returned by a guard so a refusal,
a failed spawn and a panicking read all give it back — a leaked slot
would be a permanent refusal, not a transient one.

* fix(diff): stop the overlay re-walking the tree per frame, and re-probe a folded-in refresh

Two things the shared-probe work left on the render path.

The overlay asks six whole-snapshot questions while building its element
tree — oversized, totals, retained lines, budget fired, per-file cap
fired, untracked count — and each accessor walked `files` on its own,
`oversized` walking the hunks too. `files` is deliberately uncapped (only
hunks are), so that was six walks over a list whose length is the size of
the working tree, on the UI thread, on exactly the tree this module
exists to keep responsive. `DiffSnapshot::stats` answers all six in one
pass and the per-question accessors are gone, so nothing can drift from
it. Computed rather than stored, because the snapshot is built by hand
with `..Default::default()` throughout the tests and a cached count would
read as zero for every one of them.

Deduping probes per repository is what makes one `git diff` answer every
watcher, but a probe describes the tree as it was when it *started*. A
refresh triggered after that — a command finished, an agent turn ended —
folded into the running probe and was answered with a snapshot already
known to be stale, with nothing left to trigger another look: the
overlay's own re-check is gated on `loading`, which the landing clears,
and the `GitStatusCache` change that would have re-armed it has been
spent. A folded-in request is now remembered and re-issued when that
probe lands. It converges rather than loops, because a quiet tree never
sets the flag.

* fix(diff): bound the stream queue, and stop two thresholds answering the wrong question

Review follow-ups on the sidebar-diff branch. Five findings, four of them
about a bound that was claimed but not held.

The remote streaming read bounded both ends and not the middle. The reader
thread serves the whole connection, so it cannot wait on a slow consumer —
parking it there stalls every other reply and the keepalive with it — and an
unbounded queue was the price. That reassembles the whole diff in a channel,
which is the peak the buffered read was replaced to avoid, one container
further along. The queue is now bounded instead of back-pressured: each chunk
is charged to the stream's arrears, the drainer credits them back, and a
stream 32 MiB behind is cut loose with an error rather than served. Real
back-pressure would need credit-based flow control in the dialect; this is
not that, and says so.

`oversized` counted untracked paths on its file axis, and collapsing every
file body removes no untracked rows — that section has no bodies to fold. A
tree with an un-ignored node_modules and three edited files hid the three
cheap things, kept the expensive one, and told the reader their working tree
was too large to render. The untracked list is bounded where it is built:
MAX_UNTRACKED on retention, MAX_RENDERED_FILES on rows.

AUTO_COLLAPSE_TOTAL_LINES counted the context lines git prints around every
hunk — four to six retained per line actually changed — against a threshold
set as if it were reading `+N -N`. It fired on trees whose header said 400.
8000, compared against the 20000 the parser stops retaining at, since
collapsing everything is the heavier of the two interventions and should not
arrive first by much.

A probe that could not run produced an empty file list, which renders as
"Working tree clean" — a claim about the repository, made because a read
timed out. Newly reachable, too: a stream can be refused or go silent where a
buffered read could only arrive or error. DiffSnapshot::read_failed keeps the
two apart.

Also: StreamStop's doc comment had been glued onto StreamSlot, leaving the
enum undocumented and the guard described twice; the overlay header asked
totals() beside stats() rather than through it; and the watch-teardown fix
riding along on this branch was in neither the PR body nor the CHANGELOG.

Tests: the queue budget both ways (a stream that outruns it is cut loose, a
larger one that is drained is not), the untracked axis, an ordinary
context-heavy afternoon sized to fail against the old threshold and pass
against the new, and empty-because-broken against empty-because-clean. Each
was checked to fail against the behaviour it replaces. 1603 pass, 0 fail;
fmt clean; no new clippy warnings in the touched files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:26:55 +08:00
a4972d32d8 feat(core): daemon-owned workspace tree — semantic ops, incremental deltas, thin clients (#260)
* refactor(daemon): share one run_daemon between tty7 and tty7-server

Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.

The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.

* feat(core): daemon-owned machine tree with semantic operations

Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.

Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.

* feat(control): machine-tree verbs and incremental Layout deltas

Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.

The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.

The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.

* feat(daemon): pane facts flow from the pane server into the machine tree

The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.

The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.

* feat(ui): hold a supervised control link to the local daemon

The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.

Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.

Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.

* feat(control): attachment and takeover ride the machine tree too

WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.

WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.

* fix(core): review hardening for the machine-tree foundation

Findings from a correctness review of the new daemon-owned tree,
applied together:

- A dead pane can no longer be resurrected in the tree by its own last
  output. On Windows the exit monitor reports the death while the
  reader is still draining ConPTY's buffered bytes, and the death
  report is latched; the reader's 'output is proof of life' publish now
  asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
  lock but delivered after releasing it, so one writer's deltas could
  overtake another's and leave every mirroring client on the losing
  state with no cue to re-pull. A notify-order mutex now spans each
  mutation and its own fan-out; cheap, because subscriber callbacks are
  enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
  the close paths' heal now emit ActiveTabChanged, so a client applying
  deltas never re-implements the server's heal rule; the one
  inexpressible case (no tabs) needs no delta because it is a fact,
  not surgery.
- The coarse agent status no longer drives disk writes: it flips per
  hook event and is display-only, so it is outside the changed-facts
  gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
  tty7-server configures no log sink, and 'no machine tree' was
  invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
  backoff step late; the observation-slot test withdraws its store so
  it cannot swallow later tests' observations; and locked()'s poison
  rationale now says what is actually guaranteed.

* feat(control): let clients mint workspace and tab identities on create

A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.

* feat(ui): windows speak semantic tree operations for every structural change

The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.

The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.

Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).

session.json is still written in parallel; it retires with the read-path
migration.

* feat(ui): local windows restore by asking the daemon's tree

The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.

The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.

Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.

* feat(ui): live windows apply the machine's incremental layout deltas

The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.

A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.

* feat(daemon): report panes the machine tree no longer references

With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.

* feat(ui): remote workspaces read and write the machine tree like local ones

Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.

Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.

* refactor(core): retire the client-side pane-identity defenses

The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.

forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.

* refactor(ui): a local daemon restart rebuilds from the tree too

The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.

* docs(core): drop a stale reference to the retired record verbs

* fix(ui): close the review findings on the tree migration

Review fixes, worst first:

- Pane ids never alias across daemon restarts: the pane registry seeds
  its counter past everything the persisted tree references. A fresh
  process minting from 1 handed new shells ids that dead leaves still
  claimed — the tree marked the wrong pane live, revival stalled forever
  on 'already part of this machine's tree', and an attach by the stale
  id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
  a window whose hydration has not answered is empty because it is
  waiting, and closing or swapping it mid-pull was deleting populated
  trees. Remote workspaces also hydrate regardless of the restore
  setting — their panes are running sessions, not a saved layout, and
  the restore-off swap used to open them empty-and-authoritative and
  close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
  are invisible in the desired tree without being absent, and the Full
  diff was closing them (spending the records the landing spawns'
  PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
  TabCreated/TabRestructured attached to their fresh panes and stole the
  streams they were typing into. The mirror is dropped instead; taking
  the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
  the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
  computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
  pane id against remote ids; delta-applied ratios clamp to the GUI
  band; async completions use get_mut so a forgotten window's sync state
  is not resurrected.

* feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces

The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.

The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.

This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.

* refactor(ui): client persistence shrinks to pure window views

The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:

- WorkspaceStore::claim answers only the id; record shrinks to
  record_geometry. claimable_session / record_session — the
  reachability-gated layout cache — go entirely, and with them the
  one-time empty-tree import in finish_hydration: with no cached copy
  there is nothing to import, and the machine answering "no tabs" is
  the layout.
- The user-set name is purely the machine's fact now. rename /
  rename_locally leave the store; the chip and switcher renames fire
  WorkspaceRename directly (tree_sync::rename_workspace), the
  WorkspaceRenamed delta needs nothing from the window because the
  mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
  (display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
  deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
  source, restore-off governs what launch comes back to, not what a
  deliberate switcher pick shows.

The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.

* refactor(server): retire the opaque workspace record store

Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:

- core::workspace_store is deleted. Attachment and the data-directory
  resolution (TTY7_DATA_DIR, XDG fallback chain) move into
  core::machine, which was already their only consumer; Attachment
  loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
  payload they answered with, and the WorkspaceChanged event. Their
  serde names (and the workspace-store capability bit) are recorded as
  burned rather than reserved by any mechanism — the dialect has no
  numbered slots to hold, so a comment at each site is the guard, plus
  the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
  per-connection store subscription and its WorkspaceChanged forwarder,
  and the store half of attach/detach/teardown. Attachment data now
  lives solely in the tree: a workspace the tree does not list records
  no data half (the registry's live handles still move, so takeover
  behaviour is unchanged), and it appears the moment the workspace
  does. Services::with_workspaces/and_machine collapse into
  with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
  to workspaces created in a real tree; the record-store round-trip and
  fan-out tests go (tests/machine_tree.rs has carried the tree
  equivalents since the verbs landed), and tests/workspace_store.rs is
  deleted with the serde_json dev-dependency that existed only for it.
  machine.rs gains the two guarantees the old suite held uniquely: an
  attachment dies with its workspace structurally, and the default path
  resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.

* refactor(ui): rename RemoteConnections to HostLinks

Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.

* fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name

A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.

The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.

The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.

* docs(core): finish pointing the last session.json references at views.json

* fix(ui): kick every local window's sync when the local link comes up

A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.

Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.

* fix(ui): read a deleted workspace's kill list before the removal blanks the mirror

delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.

* fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit

The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.

PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.

Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.

* fix(ui): preempted windows stay passive and take-back rebuilds from the tree

Two halves of the same takeover contract were broken.

A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).

Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.

* fix(ui): delta application survives pulls in flight

Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:

- A TabCreated straddling a pull was applied by both — the snapshot
  already carried the tab, and the delta inserted a second copy into
  the machine mirror and the window mirror, and rebuilt a second GUI
  tab whose attach stole the pane's single stream from the window
  itself. All three application sites now replace by id.

- A delta arriving while a window's prime/hydration was in flight was
  applied to the window even though the mirror side skipped it — a
  TabCreated landing in a still-empty window made finish_hydration
  read 'the user got here first' and skip adopting the tree, leaving
  the window with only the concurrently-created tab forever. Window
  application is now gated on the mirror being primed; the pull's
  snapshot carries the delta's effect.

- A prime answered after a newer cycle (hydration, desync, preemption)
  replaced it would install its stale tree over a mirror that had since
  advanced, and the next diff would re-emit the rollback as operations.
  Every cycle now stamps an epoch, and pulls landing under an old one
  are dropped.

* fix(ui): apply ratio deltas in the server's clamp band

set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.

* fix(core): machine-store hardening around seeds and unreadable files

- A PaneSeed entered the registry live:true unconditionally. A pane
  that died between its spawn and its adopting operation had its death
  observation dropped (note_pane_facts ignores panes the tree does not
  hold), and nothing ever flipped the record back — the leaf claimed a
  live pane forever and revival was never offered. The daemon now
  installs a liveness probe on the store (registry-backed), consulted
  at registration; without one (tests, clients) the seed is trusted.

- seed_ids_past computed max + 1, which panics a debug daemon at
  startup when the persisted tree names u64::MAX. saturating_add parks
  the counter at the ceiling instead.

- load_machine quarantined an unparseable file but not an unreadable
  one: a read failure logged, started empty, and the first mutation
  overwrote the very file that could not be read. Read failures now
  quarantine too — by rename, since a copy would need the read
  permission that just failed.

Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.

* feat(control): announce dropped layout deltas so lagged clients resync

A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.

* fix(ui): a pure native-SSH tab is invisible to the tree, not held forever

Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.

Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.

* docs(core): drop the dead instance helper, the stale title field, and two doc lies

- local_daemon_instance() lost its last caller when the client-side
  pane-identity defenses were retired; deleted.

- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
  (deleted with the record store) and claimed pane ids restart from 1 —
  no longer true of a tree-carrying daemon, which seeds its ids past
  everything the tree names. Rewritten to describe what the field
  actually backs now.

- PaneRecord::title claimed to label panes awaiting revival, but no
  code ever wrote it: the pane's title is a live foreground-process
  query at PaneInfo time, not state the facts path observes. The field
  is deleted (serde-compatible: unknown fields are ignored on read) and
  the decision recorded where it lived; revival labels derive from cwd
  and agent.

* fix(ui): converge the tree after adopting a delta-created tab

Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).

One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.

* fix(core): review follow-ups on the daemon-owned tree

Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.

- A dropped delta announced a LayoutResync and then delivered the
  backlog behind it. The queue is FIFO, so everything still in it is
  *older* than the gap: the peer re-pulled on the notice and was then
  walked back through history it had already left — TabRestructured
  restoring the shape a tab used to have, with window and mirror
  agreeing on the stale answer so nothing recovered a second time. The
  forwarder now drops the superseded queue and sends the resync in its
  place.

- Pane facts persisted the whole document, with an fsync, from the PTY
  reader thread — once per OSC 7, so once per prompt per pane — while
  holding the lock that orders every other client's edits. A shell
  looping over directories was a write per iteration. Observations
  (pane facts, workspace_touch) now take Persist::Soon: the delta still
  goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
  the daemon flushes on the way out. The layout itself is never
  deferred.

- An ordinary output chunk paid two AgentFacts clones and a
  clone-to-compare for facts it could not have changed. Gated on the
  signals that can move one, and the compare no longer clones.

- machine.json was created 0644, naming every workspace's directories,
  the SSH user and host of every native-SSH pane, and each agent's
  session id. It is written owner-only from the first instant the final
  name exists, and a second corruption no longer overwrites the rescue
  copy of the first.

- Windows had no control listener, so on the one platform where the
  tree is the only layout store, tabs did not come back at all. It now
  serves the dialect over the transport its pane socket already uses: a
  loopback listener whose port and 256-bit token live in a user-private
  control.port beside daemon.port — its own token, not the pane
  endpoint's — refusing to rebind over a live one, since binding is
  what writes the marker. run_daemon and the GUI's local link are one
  code path again.

- Workspace names and paths came only from the machine's mirror, so a
  laptop shut since Friday listed every row as "Untitled" with a blank
  subtitle, in the picker whose whole job is offering workspaces on
  machines that are asleep. WindowView carries the label and subject
  the machine last gave, stamped on save and on detach; the tree still
  wins whenever it answers.

- liveness_of read "the mirror has not been pulled yet" as Stopped,
  which tells the user their sessions are gone on the strength of our
  own ignorance. Unknown is what that state is for.

- A WorkspaceRemove that never reached its machine was a debug line,
  though the client had already forgotten the workspace. It is now a
  warning that says what was left where.

- MachineMirrors::install landed a pull without a repaint; the two tests
  the record store's retirement took with it (a closed connection stops
  being a subscriber, concurrent connections can all write) are back
  against the tree; and CHANGELOG records the migration's one-time
  layout loss and the Windows gap this closes.

Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
2026-07-30 12:25:38 +08:00
ayamirandl0ng-ai 183b5e4950 fix(terminal): detect links across wrapped and hard-newline rows (#258)
* fix(terminal): detect links across wrapped and hard-newline rows

A URL that spans terminal rows was truncated at the first row edge, so
Cmd-hover underline and Cmd-click only saw the first line. Resolve links
over the joined logical line instead: soft-wrapped rows were already
stitched, and a new hard-wrap bridge joins rows a program split with a
literal newline when the row is full to the edge with a link char that
continues into the next row. Double-click smart-select keeps its word
boundaries and never bridges. HoveredLink now spans grid points across
rows and the underline paints every covered cell.

* fix(terminal): never bridge a hard newline into a URL authority

The hard-wrap bridge joins two rows when the first is filled to the right
edge with a link char and the next opens with one. A hard newline carries no
signal about whether the producer split a URL, so a *complete* URL ending
exactly at the right edge is bridged onto the next row's first token
(`.../a` + `README.md` resolves as `.../aREADME.md`). That false positive is
accepted: the head of a genuinely split URL is itself a valid URL, so there
is no reliable test to tell the two apart, and the address bar shows the
mistake.

The same accident promoting the *second* row to the authority is not
acceptable. `https://good.com` + `@evil.com/x` parses as userinfo per
RFC 3986, so the real host becomes `evil.com` while the hover underline
still reads `good.com` — a phishing hop wearing a trusted label. Refuse to
bridge into a `@`. Soft wraps are unaffected: there the terminal folded one
logical line, so the continuation is certain and a userinfo URL must still
resolve whole.

Also drop a dead `c != ' '` guard (`is_url_char(' ')` is already false) and
apply rustfmt, which CI enforces as a required check.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 11:31:30 +08:00
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

Reopening a remote workspace — or coming back to one whose `tty7-server`
had been replaced — landed on a screen of `tty7 — disconnected` panes with
their coding-agent conversations gone. Several independent holes added up
to that; this closes them together, and picks up the surrounding work the
same session produced.

**Telling a restarted server from a blinked link.** `ControlHelloOk` now
carries an `instance` minted once per server *process*. Nothing else in
the handshake changes across a restart — `build` and both dialect numbers
survive it — so a reconnect had no way to know its `pane_id`s were dead.
It does now: a different instance rebuilds the window from its layout
(same tabs and splits, fresh shells in the saved cwds) instead of
re-attaching to a process that is gone. An absent instance means *unknown*
and is never read as a restart.

**An attach can now fail.** `Attach` has no synchronous reply, so the
client returned `Ok` unconditionally and the daemon's `Error` frame was
read much later by the reader thread, which has no arm for it — the pane
then landed in the *link is down* state instead of falling back to a fresh
shell. The client now reads far enough into the reply to classify it on
the kind byte (the snapshot behind it can be megabytes) and hands those
bytes to the reader thread, so a successful attach loses none of its
replay. Local and remote attaches get different waits: the local one is on
the UI thread.

**The agent session survives to be resumed.** `TerminalView` raises
`AgentSessionChanged` when the pane's agent reports a new native session
id, so the layout on file catches up instead of waiting for the user to
happen to open a tab. A pane that is still connecting now carries its
agent through `PendingSpawn` — a save landing in that window used to write
`agent: null` over the record — and `land_pane` sends `--resume` when the
attach turned out to need a fresh shell.

**Ending sessions says so on file.** "End Sessions" kills the panes and
then drops their ids from the record, pushing the cleared layout to the
machine that owns it (design §10: the remote's copy wins, so a local-only
clear would be undone by the next open — the open this exists for).

**The new-tab dropdown lists the window's machine.** `Host::shells` and a
`Shells` control request (dialect v2) make the "+" menu a property of the
machine the window is bound to. A remote window filled from this
computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is
elsewhere, and every pick failed to spawn.

**An install reports its bytes.** The download and the SFTP upload each
report progress, relayed to the client over the routed connection as a
`RoutePrompt::InstallProgress`, and painted as a bar under the machine's
row in the switcher. ~8 MB across two hops behind the word "connecting…"
was indistinguishable from a hang.

**The installer compares dialects, not version strings.** `tty7-server
--protocol` prints what a binary speaks without starting it, so a connect
adopts an already-running server it can talk to rather than prompting
about a build difference and uploading 8 MB the machine did not need.

**Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row
under every machine, pushing the list a quarter of a card down) and a new
"Disconnect", which drops the connection and leaves the windows open and
read-only. The suspension lasts exactly as long as that machine has a
window on it.

Also drops three design/contract docs for the now-shipped remote-workspace
work.

* fix(session): stop one workspace's panes from being restored into another

A restart put a copy of one workspace's seven tabs — cwds, layout and
recorded agent sessions — in front of another workspace's own tabs, and
auto-resumed every one of those agents a second time: six `claude
--resume <id>` pairs running in parallel against the same conversations,
one set per window. The record-level corruption that seeded it is still
unattributed, but every mechanism that let it propagate, amplify, or go
unnoticed is closable, and this closes them.

**Panes now know their owner.** `Spawn` can carry the workspace the pane
is created for; the daemon stores it immutably and reports it in
`List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another
workspace owns (`pane_attachable`) — before this, a saved id landing on
somebody else's live pane attached silently, which is how one window
could pick up another's shells. The field rides a new `SPAWN_OWNED`
frame with a struct payload (the legacy spawn payloads are positional
tuples an old daemon cannot grow), gated on a new `pane-owner` feature
string: a client only sends it to a daemon that advertises it, so the
legacy kinds stay byte-for-byte what old daemons expect. A pane with no
recorded owner stays attachable by anyone — that is the pre-field
behavior, not a new risk.

**Saved pane ids are bound to the daemon process that issued them.**
`DaemonVersion` now carries an `instance` minted once per process (the
local twin of the control hello's), the GUI caches it at the
`ensure_running` handshake, and each local workspace records it as
`daemon_instance` beside its layout. Claiming a workspace whose ids came
from a different instance blanks them first: daemon pane ids restart
from 1, so after a reboot every saved id points at whatever unrelated
shell holds the number now, and the aliveness check cannot tell a
survivor from a squatter. A blank on either side means "cannot tell" and
never trips it. Unlike the duplicate-claim case below, this path keeps
the agent resume — the pane is genuinely gone with its daemon, and the
fresh shell resuming the conversation is the feature.

**A duplicate claim loses its agent resume along with its pane id.**
`dedupe_pane_ids` kept the loser's layout *and* its
`agent_session_id`, so the blanked leaves took restore's spawn-fresh
path and auto-typed `claude --resume` for conversations the winning
workspace's panes were still running — the doubling above. The winner
keeps the panes and the resume; the loser keeps only cwds.

**Cross-workspace saves are caught at the write.** Every terminal view
remembers the workspace whose window created it, and `save_session`
logs an error naming both ids if a window ever records a pane created
for a different workspace — the tripwire for the still-unattributed
seed corruption, so a recurrence is caught in the act instead of
reconstructed from `session.json` archaeology days later.

Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance`
and `Workspace.daemon_instance` are `#[serde(default)]` struct fields
(old peers' JSON decodes, new fields are ignored by old readers), and
`SPAWN_OWNED` is feature-gated as above. `daemon_instance` is
client-owned in the design-§10 storage split — it names the local
daemon, and the field-census test pins the classification.

* fix(session): resume the agent when a local pane dies mid-restore

`session_to_pane` decided whether to send a coding agent's `--resume`
from `restore.is_none()` — i.e. from whether the pane looked alive when
the restore started. But `alive_panes_on` runs one `List` at the top of
the restore, while the attaches happen per leaf afterwards. A pane that
exited in between failed its attach, fell back to a fresh shell inside
`spawn_shell_terminal_in`, and then landed in the `restore.is_some()`
arm: an empty shell with its conversation dropped.

`ShellParts.restored` already answers this exactly, and the remote path
already reads it in `land_pane`. Carry it onto `TerminalView` so the
synchronous local path can read it too, and branch on that instead of
re-deriving the answer from a set that may be stale by the time it is
used.

No behaviour change on the paths that were already correct: a view that
was never restoring anything reports `restored: false`, which is the
same answer `restore.is_none()` gave them.

* fix(remote): check the server instance against the record, not just memory

A remote workspace's pane ids were only guarded against server restarts
by `RemoteLinks::instances`, an in-memory map. On the first connect after
the client starts, every machine is a first sighting, so `server_restarted`
answers false — and a `tty7-server` that was replaced while the client was
closed sails straight through. Its pane ids restart from 1, so the saved
ones now name unrelated shells, and the reconnect attaches to them: the
exact id-reuse failure the local side already guards against.

`Workspace::daemon_instance` was local-only for the stated reason that a
remote server's identity is tracked live per connection. That tracking is
correct but not sufficient — it cannot survive the client restart that
makes the question worth asking.

So the field now means the same thing on both sides: which process minted
the pane ids in this record. `WorkspaceStore::serving_instance` picks the
local daemon or the far machine's server depending on the workspace, and
`finish_attempt` compares it per workspace before deciding to re-attach or
rebuild. It stays client-owned: it records what *this* client last saw, so
two clients on one remote workspace each keep their own and neither may
overwrite the other's.

An unreachable machine still records nothing, which is what keeps a good
stamp from being erased with `None` — that would disarm the next check.

Also in these three files: the §N references to the deleted design docs,
cleaned up as part of the sweep in the following commit.

* docs: drop the references to the deleted design documents

The three documents this branch removed were cited ~280 times: `design
§10`, `contract §8`, `§17` and friends in comments, five references by
file path in code and manifests, five in CI workflows and one in the
release skill. Every one of them now points at nothing.

Rewritten rather than merely stripped, because most were not decoration:
"design §10 makes the remote's `workspaces.json` the authority" becomes a
statement in its own right, and the several that carried a Chinese phrase
from the document as their justification say the same thing in English
instead. Where the reference was purely parenthetical it is simply gone.

Not touched: `PRD §7.1`, `brief §8` and the like, which name documents
this branch did not remove and were already external before it, and the
`RFC 4648 §10` test-vector citation, which is a real specification.

The `host boundary` CI job loses `(§10.6)` from its name. It is not one of
the required checks, so branch protection is unaffected.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
7194236985 fix(terminal): prevent fullwidth punctuation overlap and speed OSC mark scanning (#250)
* fix(terminal): stop wide glyphs overlapping after fullwidth punctuation

gpui's apply_force_width_to_layout tells a base glyph from a zero-advance
combining mark by whether the shaped x advanced past half the forced
width, and CJK fullwidth punctuation fails that test (U+FF08 advances
~0.47 em against a 0.6 em half-slot). In a batched wide run the glyph
after such a character was classified as a mark and painted on top of
it. Shape each wide glyph on its own line instead: the first glyph of a
line is unconditionally a base, so the heuristic never misfires.

* perf(terminal): intern wide-segment strings via char_string

Each wide glyph now shapes alone, so its text is a single-char string —
reuse the char_string memo instead of allocating a fresh String per cell
per frame. The interned SharedString is also what keys gpui's line
layout cache, so a CJK-dense repaint allocates nothing.

* perf(terminal): skip MarkScanner's Text state ahead with SIMD memchr

The scanner runs over every batch the client receives, and ordinary
output — where the only byte that matters is ESC — dominates each one.
Skip to the next ESC with memchr instead of stepping per byte, exactly
as tty7-core's OscTokenizer already does: measured on an 8 MB batch of
plausible output, 1.6 GB/s became 8.3 GB/s.

Declare memchr for the root crate — it left with the OSC tokenizer's
move down to tty7-core, and this is the first use since.

* fix(terminal): advance segment_row past each wide glyph

The unbatching change dropped the `col += 2` along with the batching
loop it lived in, so the wide-glyph arm pushed its segment and looped on
the same column forever, growing `segs` until allocation failed — the
6 GiB abort on the Windows CI runner, and a machine-freezing memory
climb under a local `cargo test`.

---------

Co-authored-by: lizhi <lizhi20@xiaomi.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-07-29 18:32:19 +08:00
zerolover 148cb2f82a fix(linux): set window icon and application ID (#254)
Set app_id "tty7" on every platform (WM_CLASS on X11, desktop-entry match on Wayland) and attach a 256px _NET_WM_ICON to X11 windows. macOS and Windows already get their icons from the bundle / exe resource.
2026-07-29 16:54:58 +08:00
l0ng-aiandl0ng-ai f660ddb1cc fix(ui): safe delete prompt default, platform-aware reveal labels, and searchable agent settings (#255)
* fix(ui): make Cancel the default button on the file-tree delete confirmation

The file tree's delete prompt was the only destructive prompt in tty7 with
the destructive action first. On macOS (NSAlert) and Windows (TaskDialog)
the first button is the Return-key default, so pressing Return deleted -
including recursive folder deletion. Linux uses gpui's fallback renderer,
which is click-only, so the swap only reorders the buttons there.

Safe option first, matching every other destructive prompt; the literal
"Cancel" is what gpui maps to PromptButton::Cancel and the Escape key.

* fix(ui): stop saying "Finder" on Linux and Windows

The file-tree context menu and the SFTP job tooltip hardcoded
Finder-flavoured labels; only the right panel's Info row was
platform-conditional. Extract that conditional into
right_panel::reveal_label() and use it at all three sites, so the action
reads "Reveal in Finder" on macOS and "Open Folder" elsewhere.

Side effect of sharing the helper: the SFTP tooltip's "Show in Finder"
becomes "Reveal in Finder" on macOS, converging a third name for the
same action.

* fix(settings): index the Grok Build agent row and re-align drifted index titles

The Grok Build agent renders a settings row but had no search-index
entry, so the row was unreachable by search. The other five agent
entries carried mechanism suffixes ("Claude Code hooks", "Pi
extension") that no longer match the rendered rows, which are titled by
HookAgent::display_name(); the index said "Option acts as Meta" where
the row says "Option (⌥) acts as Meta".

Align the titles with the rows, keep the mechanism words as search
keywords, and extend the index tests: the pinned-title list gains the
Option row, and a new test derives the Agents entries from
HookAgent::ALL so adding or renaming an agent without updating the index
fails the suite.

* no-mistakes(review): add plain search keywords for Option-acts-as-Meta entry

* no-mistakes(document): document copy fixes in changelog and apply rustfmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 16:19:43 +08:00
l0ng-aiandl0ng-ai 6f842c3007 fix(file-tree): stop a watcher event repainting a window with nothing to draw (#249)
* fix(file-tree): stop a watcher event repainting a window with nothing to draw

Issue #243 made two claims. The flicker was fixed independently on main by
4814d94 "fix(remote): keep file-tree listings on screen while they refresh",
which reached the same mechanism separately and went further on two axes. This
is only the other claim — the one in the issue's title, and the one still true
on main: `file_tree_apply_fs_events` ended in an unconditional `cx.notify()`, so
every batch from a watcher that is recursive over the root repainted the whole
window. The overwhelming majority of those batches name a directory the tree has
never listed and does not show: `.git` internals, build output, `node_modules`.
A window with nothing new to draw was being asked to draw, several times a
second, for as long as anything under the root was being written.

* `invalidate_dir` reports whether it reached anything the tree holds, and a
  batch that reached nothing returns without repainting.
* What it did reach is re-read from the callback itself, rather than by
  notifying in order to buy the paint that would have re-read it.
* `land_load` returns `Landed { superseded, changed }` and the caller repaints
  only on `changed`, so a file rewritten in a directory on screen costs no
  frames. `TreeEntry` gains `PartialEq` for that comparison.
* The `.gitignore` whole-cache branch is scoped by `gitignore_reaches_tree`:
  patterns at `D/.gitignore` govern `D` and below, so unless a tracked directory
  sits under `D` there is nothing to refresh. Its `cx.notify()` is deliberately
  kept when the branch *is* taken — `invalidate_all` restarts the search and
  only a paint re-walks it. `InFlight::pending_keys` is new, for the in-flight
  half of that test.

Measured, not asserted. `Tty7App::render` feeds a per-thread draw counter under
`cfg(test)`, and gpui's test build redraws dirty windows from inside
`flush_effects` — so a headless window plus that counter answers "does this
reach render idle?" with no compositor and without the reporter's Wayland
session. Supporting seams: `test_window::harness` / `harness_with_pane` and
`terminal::view::quiet_test_pane`. `render_probe::arm` takes a draw budget so a
repaint-loop regression fails the test instead of hanging the suite — the loop
lives inside one `flush_effects` call and nothing outside it can interrupt it.

Render idle is measured as "stops drawing", not "never draws again": settling
legitimately costs a last frame as the final listing lands, so the count is
taken over a second interval once the first has absorbed that tail. Confirmed
rather than assumed — the count is 1 at 3s and still 1 at 12s.

Numbers, each taken by reverting the hunk it belongs to and re-running:

    five writes under an unlisted directory      5 draws -> 0
    five rewrites of a displayed file           10 draws -> 0
    five .gitignore writes under node_modules   10 draws -> 0
    marks left unread after a gitignore refresh  1      -> 0

The four controls — a settled panel on a non-empty, an empty and a hidden-only
directory, and a real change still arriving — pass either way, which is the
point: they were never the bug. `untracked_paths_leave_no_bookkeeping_behind`
also passes either way, because main's `invalidate_dir` already only marks what
is cached; it is a guard, not a fix.

Two things stated rather than faked. The end-to-end through a real OS watcher
did not survive: the watcher moved into the host layer, which a synthetic
`file_tree_apply_fs_events` cannot drive. For the same reason the gitignore test
asserts the marking and the re-read rather than the recomputed `ignored` flags,
which the host owns — and says so where it stops.

This commit replaces four earlier ones on this branch, squashed because they
were written against the single-host `file_tree` that no longer exists and could
not be replayed onto it. What each contributed:

* 744a08d fix(file-tree): stop the watcher blanking the panel and repainting for
  nothing — the original. Its flicker half is dropped in favour of 4814d94; its
  render-idle half is what this commit is.
* a88544c no-mistakes(review): scope gitignore refresh to the tree and cover
  in-flight loads — the `gitignore_reaches_tree` scoping, kept. Its other half,
  covering in-flight loads in the whole-cache refresh, is not carried: main's
  `invalidate_all` already stales them via `InFlight::invalidate_all`.
* 0ff3b64 no-mistakes(document): correct stale watcher comment in file_tree docs
  — folded into the doc comments here.
* f0e33a4 no-mistakes(document): drop tracked AGENTS.md, move render-probe caveat
  into source — the deletion is moot on this base (the file was never added),
  and the caveat it relocated is in `render_probe`'s doc comment.

Separately and deliberately not fixed here: on macOS the watcher reports paths
through `/private/var` while the cache is keyed by the root as handed in, so a
root reached via a symlink never matches and its changes are missed. Pre-existing
and invisible on Linux; the tests canonicalize around it and say so.

1484 tests pass across the workspace, fmt clean, clippy unchanged from
origin/main's baseline at 57 warnings. The app was not built, launched or driven;
visual acceptance is the owner's.

Refs #243.

* no-mistakes(review): correct watch scope claims, repaint on moved root, skip hidden-panel reads

* no-mistakes(review): exclude the SFTP column from the tree-drawn gate, fix stale docs

Also carries the correction the earlier messages on this branch owe the reader.

The first commit's message (and the CHANGELOG entry it shipped with) asserted
that the file tree watches its root **recursively**, and justified the whole
change on the traffic that supposedly produced: `.git` internals, build output,
everything under `node_modules`. That was wrong, not merely imprecise. The watch
is non-recursive — `sync_watch`'s own doc says so, it covers roots plus expanded
directories, and `WatchedDirs::translate` enforces it per backend. The recursive
watcher belonged to the older single-host design; the premise was carried across
the port to the host-keyed tree without being re-checked.

What is actually reachable, and all this now claims: the tree hears about a
change in a directory it is *displaying*, and a file's contents being rewritten
reports exactly as loudly as a file appearing. Comparing the re-read against
what is already on screen is what stops that repainting a window with nothing
new to draw.

Measured against that reachable case only, by reverting the comparison and
re-running: five rewrites of a file in a displayed directory cost 10 frames and
now cost 0. The figures the first message quoted for writes under an unlisted
directory and for `.gitignore` writes under `node_modules` are withdrawn — the
tests behind them synthesised watcher events this watch cannot deliver, so they
described scenarios the system cannot produce. Those tests have been removed or
reframed as guards on the predicate rather than evidence of a live symptom.

The other corrections in this round:

* A `.git` create or delete cleared the repo-root cache and then took the new
  early return, so nothing re-resolved it and a moved repository root no longer
  re-rooted the tree on an idle window. That regression came in with this change
  and is closed.
* The watcher-driven re-read was not gated on the tree being drawn, so a hidden
  panel did filesystem work it never used to do. It is gated now.
* That gate then assumed the Files tab always draws the local tree, which it
  does not: a connected native-SSH pane substitutes the SFTP browser and the
  local tree is never rendered. The predicate accounts for that too.
* Two stale statements of the recursive premise survived the first sweep, in
  `assets.rs` and `code_editor.rs`, and are corrected.

The SFTP-substitution case is covered by a test at the predicate; the
substitution itself is a render-path branch with no headless seam, so what is
asserted is the predicate's answer rather than the panel's output.

* no-mistakes(review): gate watcher re-read on listings drawn, fix stale docs

* no-mistakes(review): delete withdrawn recursive-watch CHANGELOG entry duplicated by rebases

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 16:09:04 +08:00
l0ng-aiandl0ng-ai 9c00648875 fix(ui): keep the window-drag arm alive across a repaint, and make every header draggable (#252)
* fix(ui): make every header draggable, and keep the arm alive across a repaint (#221)

Two changes to the same code, which is why they land together.

Five rows that stand in for the title bar — the tab rail's top zone, the
settings page's top strip, the detail panel's top zone, and the code and
diff overlays' headers — armed their drag with an `Rc<Cell<bool>>`
allocated inside the render function. A redraw between the press and the
first drag event handed the next frame's listeners a fresh, zeroed cell
while the press had written to the old one, so the whole hold was dead
until you released and tried again.

The press itself schedules that redraw: these rows carry `on_double_click`,
and gpui calls `window.refresh()` on mouse-down for any element with a
click listener. So a drag only survived if the first move beat the next
vsync — 16ms at 60Hz, 8ms on ProMotion. A mouse press physically nudges the
pointer and often won that race; a trackpad press is a finger pushing down
without translating, and almost never did. That is the trackpad-vs-mouse
split the issue reports. The terminal's cursor blink (a 530ms `cx.notify()`
loop) disarms it on its own even with no press at all.

`window_move_gesture` now holds the flag in `window.use_keyed_state`, which
survives frames — where gpui-component's own `TitleBar` has always kept it,
and why the ordinary caption strip was never affected. Keyed rather than
`use_state` because one builder serves several call sites and `use_state`'s
`CodeLocation` id would collide when two of these rows are on screen at
once (the rail's top zone plus an overlay header is a real combination).

A longer-lived flag has to be cleared explicitly, so releasing outside the
row disarms too; with a per-frame cell the frame boundary did that for free.

Nothing else about these rows changes — same hit boxes, same geometry, same
`WindowControlArea::Drag`, same double-click.

Grabbing the window by a header is a property of the whole app, not a
per-surface feature, so a user never has to learn which rows are draggable.
Written down beside `window_move_gesture`, along with the two things it
takes beyond arming the gesture: non-controls inside a header take no hit
box (the rule #202 set for the "duo" mark, so the drag falls through them),
and a header whose contents *do* take hit boxes by design needs a floor on
its flexible spacer.

- `panel_title` — the detail panel's section header, shared by Info,
  Outline, Changes, Files and the remote Files browser — is draggable now.
  Its one un-`occlude()`d control (SFTP's refresh tile) gains the wrapper
  every control on a drag row needs, or Windows' HTCAPTION eats its clicks.
- The horizontal tab strip keeps a bare 80px slice of caption. Its spacer
  was a `flex_1` with no minimum, so it collapsed to exactly 0px once the
  chips saturated the row (~7-8 tabs on a 1440px window), leaving only three
  6px gaps and a hairline above and below the chips to grab — the "the
  region that works seems very small" half of the report. The chip row's
  fixed-chrome reserve is corrected to match: a stale flat 100px, sized when
  the corner held a 30px "+" and a 30px "⋯", becomes the ~137px the corner
  actually occupies plus the handle. Chips reach their minimum width and
  truncate a tab or two sooner, and the window is always grabbable.
- The rail's top-zone spacer gains the same floor.

`ui::app::window_drag_tests` drives the real `title_bar_drag` row through
gpui's test platform, where `start_window_move` is `unimplemented!()` and a
panic is therefore a reliable "the window would have moved" detector. It
pins the invariant (press → repaint → move still drags), that a press alone
does not, that a release disarms, and that two rows on screen keep separate
arms. A control test keeps the old per-frame-cell pattern alongside and
asserts it still loses the drag to the identical event sequence — without
it, the invariant test could pass for the wrong reason.

* no-mistakes(review): occlude resize handles; correct chip-reserve arithmetic

* no-mistakes(document): reorder changelog sections; record non-draggable header exclusions

* no-mistakes(document): make panel grab-handle docs version-neutral and platform-accurate

* no-mistakes(document): make workspace_head panel-width doc version-neutral

* docs(changelog): re-file Unreleased entries after the rebase onto main

The rebase onto 64403cf applied every hunk without a conflict and still
produced a wrong file, which is the failure mode worth naming: this
branch's "reorder the Unreleased sections" commit moved its own entries
to Added -> Changed -> Fixed, and replaying that on a main whose
Unreleased had grown three new entries wedged this branch's ### Changed
and ### Fixed headings into the middle of main's ### Added list.

The result had two of *other people's* entries — "Fork an agent session"
and "Copy Session ID", both Added, both from #211 — orphaned under this
branch's ### Fixed, and a duplicate ### Changed / ### Fixed pair further
down. Git had nothing to complain about; the text merged cleanly and the
meaning did not.

Restored to main's structure with this branch's two entries filed under
the headings they belong to. No entry text changed on either side; all
seven Unreleased entries are present, verified against the union of both
parents.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 14:57:21 +08:00
l0ng-ai 12d8cf549b fix(ci): read the AppImage version from [workspace.package]
The crate split left the root manifest leading with `version.workspace =
true`, so `grep -m1 '^version'` returned that line verbatim and the sed
fell through unchanged. Every AppImage since was named
`tty7-version.workspace = true-linux-x86_64.AppImage`, which broke the
nightly publish job at `sha256sum -c` — the space-split name resolved to
three missing files.

bundle-linux.sh, bundle-macos.sh and bundle-windows.ps1 were already
anchored on `= "`; this was the one that got missed, and the only one
without a guard to catch the miss.
2026-07-29 10:37:56 +08:00
l0ng-aiandl0ng-ai 64403cf860 feat(terminal): expose the prompt editor's soft newline as a rebindable InsertNewline action (#246)
* feat(keymap): make the prompt editor's soft newline a bindable action

Shift+Enter and Opt/Alt+Enter have inserted a literal newline into the
command editor since the multi-line prompt editor landed in 54825d3, and
only a plain Enter submits. But the chords were hardcoded in the editor's
key handler, so `InsertNewline` was not a name the keymap knew: nothing to
put in `keybindings` in config.json, no row in Settings -> Keybindings, and
no way to move the gesture to a chord of your own.

Register it like every other action. `InsertNewline` is Terminal-scoped
(the handler lives on the terminal surface, as ClearScrollback and the find
trio already are) and the "enter" arm of the editor dispatcher no longer
looks at modifiers at all — the keymap dispatches the action before the key
reaches the dispatcher, so there is one implementation rather than two that
can drift.

The action ships with both of today's chords. A binding spec cannot express
alternatives — whitespace in a spec means a sequence, `ctrl-b n`-style —
and the effective table holds exactly one keystroke per action, so the
table carries shift-enter and alt-enter is installed alongside it, but only
while the action still sits on its default. Rebind it and both old chords
are retired, which is what moving a binding is supposed to mean.

The prompt editor alone answers it. With a foreground application on the
pane, the search field focused, or a completion menu / reverse search
holding the keyboard, the handler propagates, so the chord takes the exact
path it took before this action existed — a full-screen program still
receives Shift+Enter as its own chord under the Kitty protocol. Plain Enter
still submits, and the secondary-enter / secondary-shift-enter window
bindings are untouched.

Refs #182

* fix(prompt): let the newline chords through an open completion menu

Follow-up to the InsertNewline action, from review findings, resolved
against Warp as the reference implementation.

The action declined whenever a completion menu was open, on the theory
that propagating preserved the old behaviour. It did for Shift+Enter,
which used to reach the picker's accept-line arm, but not for Alt+Enter:
the picker branch is gated on `!m.alt`, so Alt+Enter skipped it entirely
and fell through to the enter arm's newline. Propagating sent it to an
enter arm that now unconditionally submits, so Alt+Enter with a menu open
ran the command.

Warp does not let the popup take these chords at all. Only a bare Enter
reaches the popup-acceptance path (a fixed "enter" binding routed into
`input_enter`, where menus and the completion popup consume it); Shift+
Enter, Alt+Enter and Ctrl+J dispatch their own actions, which the editor
resolves as a newline against `EnterSettings` without the popup ever
seeing them. Its TUI has no popup in the input's dispatch path at all.

So insert unconditionally and close the menu — a newline ends the word
being completed, and a menu still filtered on that word would be stale.
Shift+Enter with a menu open therefore changes from accept-line to
insert, deliberately and in the direction of the reference; Alt+Enter
goes back to inserting, as it did before the action landed. Plain Enter
keeps accepting the highlighted candidate.

Also repeat the dispatcher's per-key state resets (`editor_goal_col`,
`last_word_nav`), which the inline branch inherited by running inside
`handle_editor_key` and the action bypassed — the same reason
`commit_text` repeats them for the IME path.

Shift+Alt+Enter, which the old `(m.shift || m.alt)` test caught by
accident, is left submitting: gpui matches modifiers exactly, and Warp's
key table has no arm for that chord either. Recorded in a comment and a
test so it is not "restored" later as a missing default.

Refs #182

* no-mistakes(review): scope rebind's NoAction to its context; see extra chords

* no-mistakes(document): document rebindable InsertNewline chords; fix rustfmt and changelog link

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 01:05:08 +08:00