Commit Graph
980 Commits
Author SHA1 Message Date
l0ng-ai 657bb4e0b7 chore(assets): crop the hero screenshot tighter around the window 2026-08-11 21:54:19 +08:00
l0ng-aiandl0ng-ai ce4a19ae89 fix(remote): let the supervisor's view of a link be the only one (#517)
* feat(i18n): give the strip and the switcher the words for a link that is retrying

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Closes #432

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

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

Two more things the note quietly took away.

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

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

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

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

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

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

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

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

Windows Terminal made this move in 2021 (microsoft/terminal#10967) after
the same symptom — distros "missing entirely" on first launch. It skips
distros whose key carries `Modern = 1`; we must not. That is a
deduplication rule specific to Terminal, which modern distros hand a
profile fragment of their own. Nothing hands tty7 anything, and on an
up-to-date machine `Modern = 1` is the ordinary case — on the box this was
written on, the only distro installed. There is a test pinning that.
2026-08-11 09:40:37 +08:00
l0ng-ai c5a0d8665b perf(wsl): let a distro say once where its server is
`ensure_wsl_server` re-proved everything on every pane: uname, $HOME, a
stat, a liveness probe, a look at what is running — five serial `wsl.exe`
round trips to re-learn what the previous pane had just learned. Fine once
per distro, absurd per pane.

It now keeps the answer in memory, and nothing expires on a timer, because
the answer barely rots. A tty7 upgrade renames the binary, but a new build
is a new process and the map starts empty. A distro shutting down does not
invalidate it either: `wsl.exe` restarts a stopped distro on demand, and
the bridge starts its own daemon when none is listening, so the one claim
that really does stop being true is repaired a layer below without anyone
asking.

What is left is a path that could stop existing — the distro reinstalled,
the directory cleaned out. Starting the bridge is what discovers that, so
the router forgets the distro and proves it again from scratch, once. The
two operations that deliberately disturb what is running forget first, so
a restart that fails halfway leaves no note claiming otherwise.
2026-08-11 09:40:37 +08:00
l0ng-ai f676fb96de perf(wsl): stop asking twice whether a distro is ready
Every pane on a WSL workspace ran `ensure_wsl_server` from the client
before it even connected to the daemon — and then the daemon ran the very
same probe inside `router::open_link` before opening the link. Two full
rounds of five serial `wsl.exe` calls, to learn one fact.

The client's copy bought nothing. It threw the answer away and kept only
the error, which the route ack reports just as well; and the consent
question for a first install still finds its way here, because the daemon
runs its probe under `RouteSetup::blocking`, which installs the relay that
turns that question into a frame on this connection.

Measured on a distro that was already running and connected: 800ms to open
a tab, down to 440ms. Issue #454 is the same code on a machine where one
`wsl.exe` round trip takes 3.3s, where the duplicate was costing 15s a tab.
2026-08-11 09:36:51 +08:00
l0ng-aiandl0ng-ai 707fd1867b docs: add a Mintlify documentation site (#478)
38 pages under docs/, written against the source rather than the README:
config keys and their clamps from core::config, default keybindings from
ui::keymap, every CLI verb and flag from tty7-cli, agent aliases and
hook/fork/resume support from core::cli_agent, and Settings paths taken
from the actual en-US strings.

docs/features.md and its zh-CN translation are retired — everything in
them now lives in a page of its own, plus the two things they carried
that nothing else did (IME input, the performance notes). README and
README.zh-CN point at docs/ instead.

Screenshots and videos are placeholders for now: docs/images/placeholder.svg
with a caption naming what each shot should be.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 00:38:11 +08:00
l0ng-aiandl0ng-ai af3928da1e fix(tree-sync): pay back a remote window's owed tree pull (#472)
* fix(tree-sync): pay back a remote window's owed tree pull

A window opening onto a remote workspace is empty until `hydrate` pulls
the machine's tree and rebuilds its tabs from it, and it has to be: an
empty window diffs into "close every tab", so `sync_window` holds
anything back until the pull lands. When the pull fails,
`owe_rehydration` records the debt and returns, on the promise in its own
doc comment that the next sync settles it — "which is what a reconnect
does through `on_link_up`".

`on_link_up` is called for `HostId::LOCAL` and nowhere else. On a remote
host the debt was only ever settled by a reconnect completing, by an edit
in the window, or by restarting the app. So a pull that failed while the
link stayed up was never noticed again: no reconnect, and an empty window
has nothing in it to edit. The window sat on the home page with every tab
and every shell still on the machine, and only a restart brought them
back.

Two ways to fail a pull with a healthy link, both routine. A `MachineGet`
can overrun its ten seconds on a slow link. And a `WorkspaceCreate` can
lose its race with `start_prime`, which runs the same create from the
other side of the same window opening — that one fires on every remote
workspace opened, and is only invisible because the workspace it usually
lands on is empty anyway.

So: arm a backed-off retry when the debt is taken on, drive it through
`sync_window` where the rules about whether a window may still adopt the
machine's layout already live, and stop treating a lost create as a
failure — read the tree again and hydrate from what is really there.
`on_link_up` is also wired to a remote link coming up, which is what the
comment always claimed: a link the switcher connects finishes no attempt,
so nothing told its windows the machine could be reached.

* fix(tree-sync): end the backoff with the run of failures, and stop shouting

Review follow-ups on the owed-pull retry.

The attempt count paces the retry, so it has to mean "failures in a row",
but it was only cleared when a hydration landed. A debt abandoned rather
than paid — a `Replace` dropped because the user filled the window in
themselves — and a prime that landed both left it standing, so the next
first failure waited the 30s cap on an outage that was already over. It
is now cleared wherever the run ends.

A window left open on a machine that is really gone retries forever by
design, which meant a warn and an info every ~45s for as long as it stayed
open. Once the backoff settles at its cap those lines stop being events
and become a fact about the machine, so they step down to debug. The retry
is exactly as persistent; only the volume drops.

Also: report the create's own refusal when the reread finds the workspace
still missing, and say at debug that the reread happened at all — the race
recovery was silent, so the extra round trip was invisible when reading a
log. And correct the comment on the window-gone guard: closing a window
drops its whole `WsState` through `forget`, debt and all, so nothing is
parked for the next opener.

Tests: the count ends with the run at all three sites, the level steps
down at the settle point, and the armed retry is driven through a real
timer (advance_clock) into the window-gone guard — the first coverage of
the retry actually firing rather than of the predicate it consults.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 23:47:29 +08:00
ARNO 12df66fb0f fix(ui): dim panes by blending terminal colors toward the window background (#464)
* fix(ui): dim panes by blending terminal colors toward the window background

* fix merge

* fix(ui): tighten the pane-dim blend
2026-08-10 23:07:37 +08:00
l0ng-aiandl0ng-ai c216b389ae fix(macos): size the DMG ourselves, and stop blaming the runner's disk (#477)
The nightly channel has been frozen since 06:32 on 2026-08-10: every run
dies in bundle-macos.sh with "hdiutil: create failed - No space left on
device", on macos-15-intel, after the build, the signing and the
notarization have all succeeded.

The host disk was never full. #476 read that message as the runner running
out of room and freed space for it; the `df -h` it added to prove the point
disproved it instead — 105 GiB available, and the run failed anyway. The
path in the error is under /Volumes/tty7, which is the image being created,
not the runner: the volume ran out, not the disk.

`hdiutil create -srcfolder` sizes the image from the bytes it is about to
copy and does not cover what the filesystem spends carrying them, so a
bundle that fits by measurement still runs the volume dry partway through
the copy. It is a threshold rather than a cliff, which is why this began
without anyone touching packaging: the binaries grew over edfadb7..fafcaa0,
the x86_64 pair is the larger one and crossed it first, and arm64 kept
building fine just underneath.

Ask for the room explicitly — twice the content plus 64 MiB. The image is
compressed on the way out, so the slack is nearly free: on a stage of this
shape, 127 MiB of empty volume cost 672 KiB in the published DMG.

Also drop #476's deletion of the build tree. It was paying for a problem
that did not exist, and the bill was rust-cache finding nothing to save and
every macOS build recompiling the dependency graph. The `mv` from that
commit stays: a second full copy of the bundle is genuinely redundant, and
nothing reads dist/tty7.app after this point.

Verified locally against a staged bundle of the real shape (73 MiB, 101
files): the image is created, mounts with every file present, and detaches
clean. The remaining unknown is only whether CI agrees, which the next
nightly answers.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 22:31:23 +08:00
l0ng-aiandl0ng-ai 05ed9fa4ff ci(macos): give the DMG somewhere to go on a runner that ran out of disk (#476)
Three nightlies in a row died in bundle-macos.sh with "hdiutil: create
failed - No space left on device", across two different commits, always on
macos-15-intel and never on arm64. The build, the signing and the
notarization all succeed; the volume simply cannot hold the disk image on
top of everything already staged on it.

At `hdiutil create` the volume carries the whole release `target/` tree, the
signed dist/tty7.app, the compressed update zip, a second full copy of the
bundle under dist/dmg-stage, and the image being written. Two of those five
are avoidable:

Stage the bundle with `mv` instead of `cp -R`. Nothing reads dist/tty7.app
after this point — the updater ships the zip, nightly.yml verifies that zip
by extracting it elsewhere, and release.yml knows tty7.app only as an
intermediate to keep out of the upload globs.

Drop the build tree before the image is written. Every binary it produced is
already inside the bundle and no later step in either workflow reads it. The
cost is that rust-cache finds little left to save and the next macOS build
recompiles the dependency graph — a slower nightly, against no nightly at
all. `df -h` runs first so the next person to touch this has the number.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 21:32:28 +08:00
l0ng-aiandl0ng-ai 6d47406544 docs(readme): add a hero screenshot of the workbench (#475)
One 1600x1132 WebP (184 KB), shown at 900 px in both READMEs.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 21:00:42 +08:00
l0ng-aiandl0ng-ai 0f5e63701e fix(ui): let the overlay scrollbars fade out again (#471)
* fix(search): wash a match in the accent, at a strength the theme can afford

A search hit was washed from the terminal palette's selection colour at a
fixed 1.45:1 against the background, so it read as a weaker selection on a
grid that is already grey on grey — and 1.45:1 is under what a hairline is
worth, spread over a whole cell.

Two changes. The tint is now the theme's accent (`ActiveAccent`, already
floored at 3:1 by `legible_accent`), which is the one colour the terminal
surface has nothing else in. And the strength is derived per theme instead
of fixed: the wash is opaque with the glyph drawn on top, so what it may
spend is the theme's own text-contrast budget. A palette with 21:1 between
text and background can afford a wash you cannot miss; one with 6.6:1
cannot, and a single constant has to be safe for the second.

The current match drops its caret-coloured outline. That existed because a
fill 2.1:1 off the background could not say "this one" on its own; now that
it sits at the top of the theme's budget, the outline is the same colour
saying the same thing twice.

* fix(ui): let the overlay scrollbars fade out again

macOS reports should_auto_hide_scrollbars() = false for anyone with a mouse
plugged in, and apply_theme turned that into ScrollbarShow::Always for every
list in the app. That preference is about legacy scrollbars, which take a
gutter out of the layout; ours are overlay bars painted on top of the content,
so Always parked an opaque bar over the switcher's tab column for as long as
the panel stayed open, with nothing to fade it.

Pin scrollbar_show to Scrolling instead, so every list fades its bar out after
it stops scrolling.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 18:36:21 +08:00
l0ng-aiandl0ng-ai 99a388331c fix(search): wash a match in the accent, at a strength the theme can afford (#470)
A search hit was washed from the terminal palette's selection colour at a
fixed 1.45:1 against the background, so it read as a weaker selection on a
grid that is already grey on grey — and 1.45:1 is under what a hairline is
worth, spread over a whole cell.

Two changes. The tint is now the theme's accent (`ActiveAccent`, already
floored at 3:1 by `legible_accent`), which is the one colour the terminal
surface has nothing else in. And the strength is derived per theme instead
of fixed: the wash is opaque with the glyph drawn on top, so what it may
spend is the theme's own text-contrast budget. A palette with 21:1 between
text and background can afford a wash you cannot miss; one with 6.6:1
cannot, and a single constant has to be safe for the second.

The current match drops its caret-coloured outline. That existed because a
fill 2.1:1 off the background could not say "this one" on its own; now that
it sits at the top of the theme's budget, the outline is the same colour
saying the same thing twice.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 18:26:18 +08:00
l0ng-aiandl0ng-ai fafcaa0685 feat(splits): draw the pane grip as Ghostty draws its own (#463)
The bar a pane was picked up by grew and recoloured under the pointer,
and showed the moment the pointer was anywhere in the pane at all. It
was loud in the wrong places: a mark on top of the terminal wherever the
mouse happened to rest, and a target that moved while being reached for.

Cut to the shape Ghostty gives its grab handle instead:

* three dots, 80x12 of reach around them, and nothing between the two
  states but ink — 0.3 in the band, 0.8 on the grip itself.
* the dots are asked for by the pane's top fifth (floored at 24px), not
  by the whole pane, so the terminal is left alone everywhere else.
* the target is there for as long as the pane can be moved, and only the
  dots come and go, so a pointer going straight for the top of a pane can
  press the grip on the frame it arrives.
* a 150ms fade in, so the dots read as arriving rather than blinking.

The fading a pane is under is now worn by the terminal it holds rather
than by the pane, which keeps the grip legible on the very panes
`dim_inactive_panes` fades — the ones being reached for.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 17:24:40 +08:00
l0ng-aiandl0ng-ai 425f87e9a4 fix(core): key the machine tree to the config directory (#462)
* fix(core): key the machine tree to the config directory

The tree resolved from $HOME while everything else an instance owns —
views.json, the scrollback, the history, both sockets, the pidfile, and
daemon.lock — resolved from the config directory. So --config-dir moved
every part of an instance except the one that says which workspaces
exist, and two tty7s pointed at different config directories, each
holding its own lock and each certain it was the only server on the
machine, still co-owned one ~/.local/share/tty7/machine.json.

MachineStore::persist writes the document whole. The second one to flush
replaced the first one's workspaces with its own, and the next daemon to
start read the survivor's tree as the machine's. An empty tree is not
distinguishable from a machine that really has nothing on it, so the GUI
does what an empty tree means and forgets those workspaces for good.

A lock and the thing it protects have to be keyed alike. data_dir() now
follows the config directory; TTY7_DATA_DIR stays as the highest-priority
override so the test harnesses keep their sandboxes.

Moving the path without carrying the file would lose every workspace at
the moment of upgrade, which is the failure this change exists to stop,
so the daemon adopts the legacy file on startup before it opens the
store. The destination already existing is the whole guard: it means a
newer run owns the tree and the copy at the old path is stale, from a
build that predates the move and still writes where it believes the tree
lives. Adopting that over the live file would hand the old tree back.

* fix(core): only the machine's own instance inherits the legacy tree

The migration moved `machine.json` into whichever config directory started
first. In the very setup this change exists to fix — a default install beside a
`--config-dir` one — that is the second instance renaming the machine's tree
into its own directory, leaving the primary to come up owning nothing. It also
fired in our own test suite, where `routed_pane` and friends launch a real
`tty7-server --config-dir <TempDir>` under the developer's own `HOME`.

Adoption is now the entitlement of the instance running out of the config
directory this machine resolves to on its own: `$TTY7_CONFIG_DIR` where the box
names one, `$HOME`'s otherwise. Comparing paths rather than asking whether
`--config-dir` was passed is what keeps the ordinary install working — `spawn`
hands every daemon it starts an explicit `--config-dir`, its own included — and
counting `$TTY7_CONFIG_DIR` is what keeps remote hosts upgrading, since a remote
`tty7-server` is launched without the flag and finds its directory that way.

Also tightens the cross-filesystem fallback: a rename that failed because
another process already carried the file over is the one benign race, not an
error to report and not something to copy over. What is left copies through
`create_new`, so "never overwrite what is already there" holds against a racing
writer and not merely against an `exists` check several syscalls old, and a
write that does not finish leaves nothing behind.

Tests: the gate both ways, the appearance hint riding along, the same directory
under two names, the copy path refusing an occupied destination, and two
cross-process cases in `machine_tree` that start a real server under a scratch
`HOME` — one carrying the legacy tree in, one leaving it alone.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 17:12:26 +08:00
l0ng-ai d223818e1b Merge pull request #461 from l0ng-ai/fix/scrollback-restore-survives-restart
fix(daemon): give a restarted server back its panes' shells and screens
2026-08-10 16:46:52 +08:00
l0ng-ai a55340ed7f fix(daemon): sweep a dead daemon's leavings on the writer's tick, not at startup
Review follow-ups on this branch.

`history::sweep` still ran at startup, three lines under a new comment
explaining why sweeping there is wrong. The reasoning transfers exactly, and
worse than by analogy: a restore carries the dead pane's commands to its
successor via `history::carry`, so sweeping before the window can ask deletes
the file the request is about. Same shape as the scrollback bug, one file over.
Both sweeps now run on the writer's tick off one shared id set, and the writer
is named for what it does.

`pane_attachable` lost its only caller when the restore path moved to
`pane_free_for`, leaving a function kept alive by the test asserting on it. The
attach site does not need to predict the listing: it tries the attach, and a
pane that is gone falls through to the fresh spawn on its own. Gone, with its
tests folded into `pane_free_for`'s.

`restored_screen` now drops the snapshot in both directions. Keeping the file
when it decoded to nothing left it to be re-read and re-rejected by every later
restore, and swept never, for a pane the tree still names.

Also: the module doc still said scrollback was off unless asked for, which is
what this branch reverses; and #449 landed the whole feature with no CHANGELOG
entry, so nothing told anyone that pane output now lives on disk.
2026-08-10 16:36:35 +08:00
l0ng-ai b3a66e75d0 fix(test): let the machine-tree seed keep up with a new pane field
PaneSeed grew a `shell`, but this test is unix-only, so a Windows box
never compiles it and never says so. Build the seed from `bare` and the
next field lands on its own.
2026-08-10 16:30:53 +08:00
l0ng-ai 852d3178c8 style: rustfmt 2026-08-10 16:10:58 +08:00
l0ng-ai 477d82524f feat(daemon): keep every pane's screen, without asking
`persist_scrollback` is gone, and with it the switch, its three
translations and the branches that read it. Keeping a capped tail of
each pane's output is now what the daemon does, not something it can be
asked to do.

This reverses the call made when the feature landed. The argument for
off-by-default was that the ring holds whatever the pane printed —
echoed tokens, `env` output, an agent's transcript — and that writing
that down should be the user's decision to make. What the argument
missed is when the decision gets made: the moment anyone learns they
wanted this is the moment a daemon has already died, and by then the
setting could only be turned on for next time. A feature whose entire
purpose is to survive an event nobody schedules cannot be opt-in.

The cost is real and does not go away: pane output now lives at
`<config>/scrollback/*.bin` on every machine, 0600 on unix and behind
the config directory's ACL on Windows, capped at 256 KiB per pane and
dropped as soon as no window can still ask for it.

Old configs naming the key still parse — nothing in `Config` refuses
unknown fields — so the key simply stops meaning anything.
2026-08-10 16:06:54 +08:00
l0ng-ai 412bfcfc90 fix(session): let a dead pane keep its id so its screen can be asked for
`pane_attachable` answered one question and was used for two. Deciding
whether to attach needs to know the pane is alive; deciding which dead
pane a fresh one replaces needs only its id — and that is the case the
stored screen exists for.

Using the first answer for the second was self-defeating. After the
daemon restarts, every pane the window held is missing from the new
daemon's listing, so the id was ruled out, so `restore_pane` was `None`,
so the window spawned a pane that had never heard of a predecessor. No
attach was tried, no restore was requested, and the screen the daemon
still had on disk was swept a tick later without anyone reading it. The
setting was on, the snapshot was written, the daemon was ready to hand
it over, and nothing ever asked.

Ownership still rules an id out, because another workspace's pane is
neither ours to attach to nor ours to show. Liveness no longer does:
the attach is still tried first and still gives way to a fresh spawn
when the pane really is gone, which is the arrangement the tree path
already argues for at length — `live` is a hint about what to show,
never the judge of what to destroy.
2026-08-10 16:06:54 +08:00
l0ng-ai 3093babddd fix(pane): say when a restore is not asked for
Dropping the request here produced a blank pane, which is also what a
pane with nothing stored looks like and what a daemon that refused would
produce. Three causes and one appearance, with nothing anywhere to tell
them apart — the filter was silent, so reading the source was the only
way to find out which had happened.
2026-08-10 16:06:54 +08:00
l0ng-ai c138be687a fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started,
both of them things the tree was the only possible place to keep.

**The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh
spec and its agent, but never what it was running. A window rebuilding a
dead pane from the tree therefore had nothing to pass and spawned on
whatever the default shell is now — so a restart turned a bash pane into
a PowerShell one, quietly and in place. The daemon resolves the override
against the config at spawn time and is the only party that knows the
answer, so it keeps it and reports it; the seed carries it too, for the
panes a window spawned itself. A handoff carries it in the blob, because
nothing on the far side of an `execve` can work out the command line of
a child it never spawned.

**The screen.** The startup sweep ran before the endpoint was listening,
which is the one moment nothing can answer the question it asks: the
registry is empty and the windows that know which screens are still
wanted cannot say so yet. A tree that failed to parse made it worse —
`read_machine` quarantines it and returns an empty `Machine`, so one bad
file took every pane's stored screen with it. The sweep now happens only
on the periodic pass, a tick later, with the registry filled in and the
tree caught up; nothing is serving a request in between. Turning the
setting *off* still clears the directory at once, because there the
promptness is the whole promise.

Two smaller ones alongside it: `restorable_pane_ids` now counts the
tree's pane list and not only the panes some tab currently stands on —
the two disagree while a window is between layouts, and being wrong
costs a file swept a tick late in one direction and somebody's terminal
in the other. And `restored_screen` drops the snapshot file *after*
deciding it was not empty, so a snapshot holding nothing is no longer
consumed by the request it could not answer.

The restore path had no end-to-end test, which is how this shipped: the
unit tests cover the file, not whether a window that reattaches is shown
anything. The new one runs a real daemon, puts a marker on a real pane,
stops the daemon, starts another, and reads the wire.
2026-08-10 16:06:54 +08:00
l0ng-ai edfadb7df2 Merge pull request #424 from l0ng-ai/feat/scm-foundation
feat(scm): a full Source Control panel, decorations and commit history
2026-08-10 14:12:21 +08:00
l0ng-ai 2dc6a88af6 merge: main into the Source Control branch
Conflicts were the two streams touching the same seams, resolved by
taking the newer decision on each side:

- main's interface font scale (rems tokens) wins in right_panel.rs; the
  SCM panel keeps its local px steps until it moves onto that scale,
  and the now-unused PANEL_TEXT constants are gone.
- main's l10n_keys! macro (idents only) means the key list carries no
  doc comments any more; our SCM keys fold into it, and PanelUntracked
  stays deleted — its only caller was the panel this branch replaced.
- main's Command::localized palette style carries our Git group; ORDER
  keeps main's visibility and our width.
- main's ansi_seed/clear_ink refactor in presets.rs carries the lane
  colours: lanes() now clears through the same helper semantics uses.
- file_tree keeps both: main's drag-and-drop targets and this branch's
  git decorations per row.
- diff_overlay keeps both: main's sidebar-count write-back on snapshot
  install and this branch's epoch read and untracked preview.
- main's window.prompt SSH-close confirmation supersedes the bespoke
  modal our branch still carried; main's tile-glyph revert stands.
- main's two new guards are satisfied: the fourteen SCM actions carry
  authored names on the Keybindings page (their palette wording, plus
  a new CmdGitToggleGraph), ja translates ScmDetached, and CmdGroupGit
  joins the kept-in-English list — Git is a name.

2571 tests, 0 failures.
2026-08-10 13:25:30 +08:00
l0ng-aiandl0ng-ai 9815f2d16f fix(windows): restore Ctrl+C in panes (#459)
The daemon was created with CREATE_NEW_PROCESS_GROUP, which disables
Ctrl+C for the whole new group — and Windows hands that "ignore Ctrl+C"
state down to every descendant. Every ConPTY shell a pane spawned
inherited it, and so did everything those shells ran: the pane wrote 0x03
and conhost turned it into a keypress, but the CTRL_C_EVENT never came,
so `go run` and `npm install` carried on. Git Bash looked fine only
because MSYS synthesises SIGINT from the byte itself and never waits for
a console event.

DETACHED_PROCESS already leaves the daemon without a console for a
control event to arrive on, so the group flag bought nothing to begin
with. Both daemon spawn paths and tty7-cli's headless server — which
spawns panes too — now share one constant without it, and
DaemonPane::spawn clears any inherited ignore before it opens the pty, so
a tty7 launched from a shell that already had the bit set is covered as
well.

The regression test has to inherit the state rather than switch it on in
place, since that is the shape the daemon was in: an intermediate process
created exactly as the daemon used to be runs both arms, and a pane must
be interruptible only after the clear. Its observable is the shell rather
than the interrupted command — after the ^C, cmd gets its prompt back and
acts on the `exit` typed behind it — so it reads no message and holds on
a non-English Windows.

Fixes #451
Fixes #314

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 13:24:23 +08:00
l0ng-ai feb027da1f feat(scm): show an untracked file's content when its row is opened
Focusing an untracked file in the diff overlay used to fall through to
the names-only "Untracked files (N)" card — git has no patch for a
file it does not know, and `--no-index` needs a null device whose
spelling is platform business. The overlay now reads the file's own
bytes (lazily, only the focused file, 4 MiB cap) and synthesizes the
card a parsed added-file patch would produce: every line an addition,
new-side numbers, true counts past the single-file budget, git's own
NUL-in-the-first-8000-bytes binary rule. A fresh snapshot clears the
preview so an edit shows up on the same cadence a tracked file's does;
a failed read says so instead of showing an empty file.

Found in manual acceptance of the panel.
2026-08-10 13:06:15 +08:00
l0ng-ai 1df43b72b5 feat(files): copy dropped files into the folder they were dropped on (#458)
* feat(files): copy dropped files into the folder they were dropped on

The Files panel has only ever been a drag *source* — a row dragged into a
terminal inserts its path. Nothing on the tree ever registered a drop, so
a file dragged in from the desktop did nothing at all, not even a
highlight. Closes #453.

The drop is the whole gesture: files land where the cursor was, not
somewhere a dialog asks about afterwards. A folder row takes them itself,
a file row stands in for the folder holding it — "next to this one" — and
the space the rows do not cover belongs to the top of the tree. The
placeholder inside an empty folder takes a drop too; it is the only thing
drawn there, and letting it fall through to the root would put files
somewhere the cursor never was. A row under the cursor wins over the
column, which is what gpui's innermost-first dispatch already does.

The copy itself goes through the `Host` the tree is listing, so a remote
workspace reads here and writes there. Locally it is `fs::copy`, which is
what keeps the executable bit that `write_file` would drop; remotely the
bytes ride one control frame, and a file too big for that is refused with
the advice to use SFTP rather than half-sent.

Names already taken are asked about before anything is written, and the
answer governs the whole drop — a half-done copy would have to be undone
to honour a "no". Replacing a folder replaces it rather than merging into
it. A drag let go where it started is a miss, not an error, so it says
nothing.

* fix(sftp): list the directory again once an upload lands

An upload is written to `<name>.tty7-upload-<hex>` and renamed into place
at the very end. The browser listed the directory the moment the transfer
was handed to the daemon, so it caught that temporary name — and nothing
ever listed again, so a finished upload sat on screen as a file with a
hash glued to its name until the directory was navigated by hand.

The premature listing is gone, and the panel now remembers the job ids it
started: once one stops running — done, failed, cancelled, or dropped off
the job list entirely — the directory is listed once more. Two uploads in
flight settle independently, so the second one finishing does not depend
on the first.

* docs(changelog): note the SFTP upload listing fix

* ci(host-boundary): allow the source side of a file drop, and stop scanning two files as empty

The Files panel now copies dropped files in, and what the desktop hands
over is by construction a path on the desktop's own machine: reading it is
a local read even when the tree being dropped on is remote. The
destination side goes through `Host`, and the one `std::fs::copy` that
touches a destination sits inside a branch already gated on
`host.id().is_local()`.

While adding that entry: `attr` starts unset, which awk reads as 0, so a
file whose first line is `mod something` matched `attr == NR - 1` and cut
its body at line 0. `head -n -1` then errored and the file was scanned as
empty — `src/terminal/mod.rs` and `src/ui/tray/mod.rs` both open that way,
and the guard had been blind to both. Neither contains a violation, so
seeing them is free.
2026-08-10 12:41:17 +08:00
l0ng-ai 58d7ef5838 fix(scm): close out the review's minor findings across the data and UI layers
The second pass over the branch review: every remaining finding verified
against the code, the real ones fixed.

Data layer:
- A truncated log parse is never called complete: RecordSplitter drops
  an overlong record whole and reports the count (delivered cut short,
  a commit body cut mid-way reads as the real message), parse_log
  carries a truncated flag past MAX_LOG_BYTES, and load_page only says
  "end of history" when the parse read everything git returned.
- Every scope pins symbolic revs to shas before walking, so a commit
  landing between two pages can no longer shift where page two starts
  under Head and Refs scopes; unresolvable names read as "no history"
  rather than as a load failure. --parents was doing nothing and is
  gone; edge sort is stable so a merge's Outs keep first-parent order.
- The lane model's central invariant now names the join case — a merge
  whose second parent already has a lane reserved sends its Out onto
  that lane, one line below the cut, not two — with a golden test for
  the commonest merge topology of all, which no golden covered.
- DiffSource revs get the same could-be-an-option guard log already
  had; C-quoted paths decode the full escape set (a tab decoded to a
  literal t broke the :(literal) re-probe); rename from/to lines
  override the ambiguous diff --git header; combined-diff line numbers
  follow the sides rather than the colour, so a " +" line no longer
  drifts every number below it.
- A rename's old path stays out of the per-file decoration map, where
  it outranked a file re-created at that path; ignored records decorate
  as Ignored, not Modified; checkout <branch> gains the trailing --
  that keeps a stale name from falling back to a worktree-clobbering
  path checkout; unstage before the first commit takes -f (worktree-
  safe with --cached); batches split by bytes as well as count for
  Windows' 32K command line; a deadline expiry reports Timeout, not
  "git could not be run"; error details keep both streams.
- probe_status distinguishes "not a repository" from "could not ask":
  a dropped link keeps the cached status (stale beats blank) and rests
  10s instead of erasing the panel, while a definitive not-a-repo also
  drops the cwd→root mappings so the panel stops drawing Loading for a
  repository that is gone. Probe and watch work are wrapped against
  panics that would wedge their in-flight bookkeeping forever, watch
  landings check the wipe counter, superseded probes relaunch through
  the debounce, and a refused network slot says so instead of eating
  the click.

UI:
- Reset --hard confirms with its own words (commits fall off the
  branch), not the discard dialog's; a merge commit whose prefilled
  message the user cleared is committable again; the disabled commit
  button distinguishes "nothing to commit" from "write a message".
- Selection highlight matches on the diff source too, so a file staged
  and edited again no longer lights both of its rows for one overlay.
- The graph materializes only the rows in the viewport window (5000
  flex children per frame was most of a frame), row clicks carry the
  page Arc and an index instead of a deep Commit clone per row per
  frame, filter results are cached per (page, query), and a selected
  merge ring's hole matches the selection band under it.
- A failed commit_files read says the list could not be read instead
  of "0 files changed"; the STAGED chip and the graph's relative
  times go through the i18n table; the keys-awaiting-a-caller list is
  pruned to the seven that still are; the orphaned PanelUntracked key
  is gone; the zh commit placeholder reads naturally.

2398 tests, 0 failures. Known flake: daemon::singleton's second-claim
test, untouched by this branch, fails ~1 in 3 full parallel runs and
passes alone.
2026-08-10 12:31:58 +08:00
l0ng-aiandl0ng-ai 35bbad5155 docs(features): drop the removed orchestration skill entry (#457)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 12:20:01 +08:00
l0ng-aiandl0ng-ai 5f1ee966ec fix(windows): give the pane grip and drags in flight a cursor (#455)
Win32 ships neither an open- nor a closed-hand cursor, and gpui's Windows
backend answers both with the plain arrow. The pane drag grip asked for
`cursor_grab()` and so read as ordinary background there, and the pointing
hand the sidebar's group header had worked around it with was dropped again
the moment a drag began, since the active drag cursor is `ClosedHand`.

Lift that workaround into `reorder::cursor_grab` so the grip and the group
header share one answer, and pick the held cursor per platform too.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 12:12:09 +08:00
l0ng-ai a764d92132 fix(scm): sequence compound verbs, cap graph paging, back off failed loads
Review findings on this branch, all in the seams between async operations:

- Commit-and-push, commit-and-sync, sync and discard-all dispatched both
  halves into the worker pool at once, so a push could resolve the branch
  tip before the commit (or pull) it was waiting for and quietly send the
  old one. Compound verbs now carry a ScmFollowUp that the first half's
  landing closure starts on success only; a refused commit, a failed pull
  or a cancelled confirmation drops the follow-up with it.
- Push sent `git push <remote> <branch>` with the branch taken from the
  upstream's name — a bare name means a *local* branch, so `feat`
  tracking `origin/main` pushed stale local `main`. The refspec is now
  `HEAD:<branch>`, and the branch is validated with the full branch
  check since a `:` would smuggle a second refspec in.
- `scm.committing` was armed before the amend confirmation and never
  disarmed on failure, so a cancelled prompt (or a hook rejection) plus
  any later unrelated HEAD move cleared a message that was never
  committed. It is armed at dispatch and disarmed when the commit errors.
- Discard-all fed staged-only paths to `checkout --`, where a staged
  deletion sank the whole batch as an unmatched pathspec. Only unstaged
  paths go in, one confirmation covers both halves, and the two gits no
  longer run concurrently.
- One "load more" click at 5000 commits grew `requested` past what
  `load_page` clamps to, so the freshness check never passed again and
  every frame refetched the full page. Growth stops at the cap, the
  button hides there, and a failing `git log` is remembered per key
  instead of being retried from every render.
- A repository switch now drops the previous repository's page before
  anything can draw it or grow from it — a stale row's context menu
  used to build ops for the new repo with the old repo's rev.
- A watch that failed to open was retried at frame rate, one host round
  trip per render; it now rests for WATCH_RETRY between attempts.
- Non-network writes on a remote host ran under the interactive
  20-second deadline while the server ran the job to completion, so a
  slow pre-commit hook was reported failed and then landed anyway. Every
  write now goes through git_with_deadline, 120s for local verbs.
2026-08-10 11:24:09 +08:00
l0ng-aiandl0ng-ai 30b16c65b5 fix(settings): keep one restart button for the stale background server (#452)
The in-place-update notice carried its own Restart server button while the
Server section right below it carried an identical one, both calling
restart_daemon. Move the notice into the Server section: the stale build
line sits under the header and its explanation replaces the generic one,
so the single button that ends every running pane is the only one on the
page.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 11:22:11 +08:00