Commit Graph
1240 Commits
Author SHA1 Message Date
l0ng-ai 0ede353724 chore(issues): split the issue form into bug and idea
The single form was doing two jobs: three of its five fields carried a
"(bugs)" suffix because they made no sense for an idea, which also meant
nothing bug-specific could be required without blocking the idea path.

Two forms instead. The bug form requires steps to reproduce, the expected
behaviour, the version and the platform, and adds a log field rendered as
code so pasted escape sequences survive markdown. The idea form asks for
the problem before the solution. Both auto-label and both open with a
duplicate-search checkbox, so the type dropdown is gone — picking the form
is picking the type.

Blank issues are off, since they let a reporter walk past every required
field. The Discussions contact link is dropped as well: the repo has
discussions disabled, so it was a dead link.

Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa
2026-09-10 18:11:20 +08:00
l0ng-ai 2ef652436b Merge pull request #856 from l0ng-ai/fix/wait-free-remote-panes
fix(cli): answer `wait --until free` on remote and SSH panes (#840)
2026-09-10 15:14:22 +08:00
l0ng-ai a88c49904c Merge branch 'main' into fix/wait-free-remote-panes
#731's `PortProbe` and #774's `TerminalModes` both landed on main while this was
open, each adding a field beside one this branch adds — `PaneProcs.probe` next to
`context`, `PaneState.modes` next to `remote_prompt_seen` — so every struct
literal for the two conflicted without either side being wrong. Both fields are
kept everywhere. `procinfo::snapshot` is #731's rewrite, with `context: None`
moved onto the `finish` helper that now builds the reply; the comment saying why
only the pane can fill it comes along.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 15:01:01 +08:00
l0ng-ai a70d58b05f Merge pull request #825 from l0ng-ai/docs/nested-shell-cwd-698
docs: why a nested shell stops reporting its cwd (#698)
2026-09-10 14:47:44 +08:00
l0ng-ai 47eca37192 Merge pull request #854 from l0ng-ai/fix/843-tab-last-focused-pane
fix(ui): remember the focused pane as focus arrives, not at switch time (#843)
2026-09-10 14:44:20 +08:00
l0ng-ai 5c387e2ba4 Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731)
2026-09-10 14:34:54 +08:00
l0ng-ai 104268ba81 Merge pull request #828 from l0ng-ai/fix/774-pty-source-and-replayed-modes
fix(terminal): a per-pty parked-cursor repair, and modes restored on re-attach (#774)
2026-09-10 14:34:00 +08:00
l0ng-ai 93e804b20e Merge pull request #824 from l0ng-ai/perf/ssh-prove-server-once-per-connection
perf(ssh): prove the remote server once per connection, not once per pane (#695)
2026-09-10 14:28:51 +08:00
l0ng-ai cf75802baf Merge pull request #835 from l0ng-ai/fix/function-keys-834
fix(terminal): encode the function keys and stop swallowing them (#834)
2026-09-10 14:27:50 +08:00
l0ng-ai 76c607a8a9 Merge branch 'main' into fix/wait-free-remote-panes
#711 landed on main while this was open and added two `ShellState` literals to
pane.rs's tests, which `mark_at_prompt` makes incomplete — the merge did not
compile on any target even though both sides did. The marks those two tests
stand up say "at a prompt", which is exactly the reading the new field carries,
so both get `mark_at_prompt: true` and the replay gate they exercise is
untouched. `cli_e2e`'s test table took both sides' new entries.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:25:06 +08:00
l0ng-ai 2ec515c206 Merge pull request #831 from l0ng-ai/fix/716-remaining-three
fix: the three remaining items of #716 — the misdirected rename, the missing adopt verb, the absent tree backup
2026-09-10 14:19:07 +08:00
l0ng-ai 0381c0270c Merge remote-tracking branch 'origin/main' into review-828
`main` dropped the agent-turn cuts from the reader's `flush_batch!` and
added `foreground_command` to `replay_state`; both are kept, with the
per-pty `repair_cursor` flag replacing `REPAIR_PARKED_CURSOR` in the
simplified cut loop and the mode restore ahead of the new signature.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:13:53 +08:00
l0ng-ai 4be07107b2 Merge pull request #855 from l0ng-ai/fix/capture-empty-and-tail-841
fix(cli): a resize was emptying capture; add --tail N (#841)
2026-09-10 14:12:45 +08:00
l0ng-ai eb419128ca fix(ui): carry a tab's focus memory onto the pane its pending slot became
Watching connecting slots is what lets a pane focused while it is still
coming up be recorded, but the record names the *pending* slot, and that
id dies the moment `land_pane` swaps the slot for the pane that came up
in it. The memory then names an entity no tab holds, `focus_target`
falls through `leaf_matching_or_first`, and the tab comes back to its
first leaf — the bug this branch is about, one landing later.

Nothing else writes the answer down in that window. `land_pane`
re-focuses the pane it built only when the pending slot still held
focus, and with focus off the panes the switch-away sample has nothing
to read either. So: split a pane, let focus wander off the panes while
the new one is still connecting, switch away and back, and you land in
the pane you did not ask for.

`replace_leaf_in` does the swap and carries the memory with it, and
`respawn_native_ssh_in_place` — the other place a live pane is
substituted for a dead one — uses it instead of repeating the walk.

Also pins the wiring this branch changed. The tests here drove
`remember_leaf_in` directly and never the subscription that calls it, so
deleting the one line that records a focus arrival left all four green.
`test_window::harness` makes the round trip reachable — real panes, real
gpui focus, a real `activate` there and back — and both new tests fail
without the code they are about.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:05:02 +08:00
l0ng-ai 8c508a5282 fix(ssh): hold the note's lock across a restart or a replace
The memo made the connection's `proved_server` slot both the note and the
install gate, and `ensure_remote_server` holds it across the probe for a
reason the two operations that change the server share: a pane arriving
mid-install must wait rather than upload over the file the winner is
renaming into place. `restart_remote_daemon` and `replace_remote_server`
only cleared the note and then let go, so a pane opening during a replace
still probed against a half-moved binary — and now *kept* that answer for
the life of the connection, where before the memo it cost that one pane
and no other. The sharp end is the mismatch the note re-files on every
hit: probe the machine while the replace has uploaded but not yet
restarted, and the outgoing daemon is filed as a mismatch that every
later pane on that connection hears about, long after the replace the
user asked for fixed it. Both now run inside the guard, the note dropped
first, the way `wsl::replace_wsl_server` holds its distro's install lock.

That makes the hold as long as a replace, so the router's forget on a
silently closed link moves off the thread polling the route: it took the
same non-reentrant lock, and waiting there kept the client's half of a
link that was already gone open for as long as whatever held it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:03:37 +08:00
l0ng-ai 8ee9474045 fix(daemon): leave the ring the modes it still carries
`replay_state` sends the mode fold ahead of the ring so the replayed
frames land in the buffer they were drawn for, and it sent every mode
that was on — including the ones the ring itself still switches on.
`?1049h` is a no-op in the emulator once the mode is already set, so the
ring's own copy then stopped clearing the alternate screen: everything
the ring holds *ahead* of that sequence, which is the shell scrollback
the user had behind the program, was painted into the alternate buffer
instead. That buffer keeps no history, so those lines were thrown away,
and the primary buffer the program's exit returns the client to was left
empty. Reconnecting a minute after opening `vim` is the ordinary case,
and it lost the prompt the user left behind.

Only the modes a replay of the ring cannot speak for go ahead of it now,
and they are read from a fold over the bytes the ring actually still
holds — so a sequence the front cut in half counts as lost, exactly as
it will for the emulator that reads the same bytes.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:13 +08:00
l0ng-ai 16b663a407 fix(procinfo): report a Windows table that would not answer, and stop crying wolf
Three things the new probe verdict still got wrong.

`GetExtendedTcpTable` refusing came back as an empty buffer, which read
exactly like a family with no listeners, so the Windows branch answered
`Ok` about ports it had never looked for — the silence #731 is about, on
the platform the panel was written on. `tcp_table` says `None` now, and
only "neither family answered" is `Unavailable`: a machine with IPv6 off
keeps its `Ok` and its IPv4 ports.

The `Unavailable` warning was written on every `QueryProcs`, and the
Info panel sends one every two seconds — thirty identical lines a minute
into a log that truncates itself at 4 MiB, which costs a reporter the
rest of the session they turned logging on to capture. Same reason, once
a minute.

And `Restricted` on Linux took `/proc/<pid>`'s owner for the process's
uid. The kernel hands that directory to root whenever it clears a
process's dumpable attribute, which is what executing a set-user-ID
binary or one carrying file capabilities does, so a plain `ping` in a
pane had the panel apologising for sockets it can read perfectly well —
the opposite mistake, and just as wrong. The `Uid:` line of
`/proc/<pid>/status` settles the few rows that look foreign and are in a
pane's tree, so an ordinary pane pays nothing for it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:08 +08:00
l0ng-ai ecd5c31d39 docs: name the settings field that picks a local pane's shell
The local remedy sent the reader to `config.json` for something the app has
a field for — **Settings → Terminal → Shell → Program**, with the
**Arguments** box beside it that turns the injection off when filled.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:00:53 +08:00
l0ng-ai e7145eabd8 fix(terminal): give the kitty protocol its own F3 and F13..F24
The shared table sent F3 as `SS3 R` / `CSI 1;<mods>R` to a client that had
negotiated the kitty keyboard protocol. Kitty's first version allowed that
letter form and then removed it: `CSI 1;2R` is also a Cursor Position Report
for row 1, column 2, so a client cannot tell Shift+F3 from an answer to its
own DSR. The protocol's table gives F3 as `CSI 13~` alone -- the VT220 `kf3`
-- and alacritty special-cases exactly this. Only the kitty path moves;
everyone reading terminfo still gets the `kf3=\EOR` we advertise.

F13..F24 were left unencodable on both paths for a terminfo reason that holds
only on the legacy one: `kf13` onwards are already the modified F1..F8, so
there is nothing to send. Kitty has no such clash -- it names them in the
private use area, `CSI 57376 u` for F13 -- so a client that asked for the
protocol now gets them, and the legacy path still sends nothing.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:00:41 +08:00
l0ng-ai aa82dfade0 Merge pull request #827 from l0ng-ai/fix/ssh-repeating-password-prompt-820
fix(ssh): stop the password prompt coming back after a reconnect (#820)
2026-09-10 13:58:52 +08:00
l0ng-ai bc5e6b6117 docs: correct the nested-shell cwd notes against the code
The oh-my-zsh guard is not at the top of `lib/termsupport.zsh` — it fences
off the cwd reporter at the end of the file, and the title hooks above it
keep running over SSH. Say that instead.

The list under "the shell is one tty7 does not integrate" is the set it
*does* integrate, which read as its own opposite; turn the sentence around.
Nushell has had the integration since #637 (`ShellKind::Nushell`, a
`nu --config` wrapper), so give it a row in the shells table and stop
listing it among the shells that have none.

The per-profile toggle lives behind **Advanced**, as `docs/remote/ssh.mdx`
and the section below already say, and the panel is the **Files** panel
everywhere else in these docs.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:58:29 +08:00
l0ng-ai a249379bf8 fix(machine): format the three items and stop reading the tree per persist
`rustfmt` is a required check and was red on three hunks of this branch:
`owner_of`'s signature, the `--cwd` argv in `tab_new`'s adopt test, and the
two `tab_create` calls in `the_document_being_replaced_is_kept_beside_it`.

`keep_a_generation` also read the whole document before asking whether it
was going to keep anything. It runs on every persist — pane facts alone
flush every couple of seconds — and answers "too soon" on almost all of
them, so that was a full read of `machine.json` per write to produce one
copy every five minutes. The spacing check moves ahead of the read; the
`NotFound` arm still covers the machine that has never written a tree.

And a line continuation was missing from an assertion message in
`a_typed_name_waits_for_the_create_rather_than_racing_it`, so the failure
would have printed eighteen spaces mid-sentence.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:57:54 +08:00
l0ng-ai dcb3cd5eee fix(daemon): keep the newest mark's reading when suppression collapses a batch
`suppress_relayed_prompt_marks` compares everything but `mark_at_prompt`, so
that two marks the local editor can no longer tell apart still collapse into
one `Prompt` message. But `dedup_by` keeps the *earlier* of the pair, and a far
shell's whole turn can arrive in a single read: the previous command's `D`, the
prompt's `A`/`B`, then the next command's `C`. With `at_prompt` cleared across
the batch those two entries differ only in the mark's own reading, and the `C`
was the one being dropped — leaving `mark_at_prompt` standing at `true` while a
remote command was starting, which `PaneContext::at_prompt` reports and
`pane_freeness` answers `free` from.

The reverse order loses the prompt instead: a command that starts and finishes
inside one read collapses onto its own `C`, and the pane reads busy for as long
as it takes some unrelated output to arrive — which on an idle prompt is never.

Both need the same thing, so carry the reading onto the survivor before the
later entry goes. `command` only tells the pair apart when the far shell names
it; nushell's integration and several third-party ones emit a bare `133;C`.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:56:15 +08:00
l0ng-ai 2805478e44 Merge pull request #832 from l0ng-ai/fix/711-replayed-prompt-scrubs-the-alt-screen
fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
2026-09-10 13:55:31 +08:00
l0ng-ai 94b0d14e16 Merge pull request #853 from l0ng-ai/fix/850-restore-preamble-clears-input-modes
fix(daemon): clear the dead TUI's input modes on restore (#850)
2026-09-10 13:54:46 +08:00
l0ng-ai 4aa24286a3 docs(skill): carry capture --tail and its bytes field into the skill reference
`skills/tty7/references/commands.md` is the agent-facing twin of
`docs/cli/reference.mdx` — the two have moved together in every commit that
changed a CLI surface — and it was left behind by this branch. It still
announced `capture [%PANE] [--plain] [--scrollback]`, "two independent
choices", and `JSON: {"pane","text"}`, so an agent reading the skill was being
told a shape the binary no longer prints and a flag list that no longer holds.

Says the same three things the reference now says: the resize boundary the
default form is bounded by and to ask for `--scrollback` under the GUI,
`--tail N` and why it trims last, and `bytes` as what tells an empty `text`
apart from a lost one.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:50:59 +08:00
l0ng-ai c0366cd143 fix(cli): name the limit of the empty-segment fix, and settle the captures the e2e compares (#841)
CI found both new end-to-end cases red on Linux and macOS while green on
Windows. One was a bad assumption in the test; the other was the test
telling me the fix is narrower than the first commit claimed.

The real finding is the resize case. On Unix a resize raises SIGWINCH and
the shell repaints its prompt, so the segment the resize opens is *not*
empty — it holds the repaint. Dropping byte-less segments therefore
leaves the newest non-empty segment being a bare prompt, with the pane's
output still stranded in the segment sealed behind it: on CI the default
form came back as 174 bytes of prompt escapes and no marker. On Windows
nothing answers the resize, the segment stays empty, and the fix reaches
past it to the output — which is why the assertion passed there. It was
asserting an accident of the platform.

So the fix stands but is smaller than "a resize no longer costs you the
pane's output": it makes the zero-byte answer impossible, and that is
all. It cannot do more. Nothing in the byte stream distinguishes a prompt
repaint from output the pane meant, so no client-side rule can tell which
side of the boundary the answer is on. The boundary is the flaw — the
default form's unit is the last resize, an event in the window rather
than in the pane — and moving it means redefining what the default
returns (the last screenful of the ring, say), which would shrink what
every caller with a never-resized pane gets today. Left alone, and said
plainly instead: in `what_was_asked_for`'s doc comment, and in a warning
in the CLI reference telling anyone reading a pane under the GUI to ask
for `--scrollback`.

The test now asserts what the fix actually guarantees on every platform —
the default form answers with bytes rather than with the resize's
placeholder, and it is the end of what `--scrollback` returns, which is
what would catch a fix reaching for the wrong segment. The marker is
pinned against `--scrollback`, the form that promises to hold it.

The `--tail` failure was a race in the test, not in `--tail`: the whole
and the tail were separate calls and the pane advanced between them, so
the tail carried a prompt line the whole capture had not caught up to.
Both cases now read the whole answer on either side of the others and
require the two readings to match before comparing anything, which is
what makes the comparison a statement about the code rather than about
the moment. Both also read `--scrollback` now, so neither depends on
where a segment boundary happens to fall, and neither pins exact pane
content — the macOS runner prints a zsh banner into the pane.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 12:05:51 +08:00
l0ng-ai 48d712143e fix(cli): answer wait --until free on remote and SSH panes (#840)
`pane_is_free` read the pane's local process tree and folded two very
different answers into one `false`. On a pane that is only the near end
of a connection that tree describes the tunnel: a pane routed to a
remote daemon has no local pty at all, so `DaemonPane::procs` returned
`Default::default()` and the tree came back empty; a pane whose shell is
running `ssh` has a tree whose depth-1 process is the `ssh` itself, busy
for exactly as long as you are logged in. Either way `free` was
unreachable structurally, `seen_busy` was set on every poll — which
suppressed the one hint that would have pointed at the gap — and the
wait rode the whole `--timeout` before recommending `--until free`, the
flag that had just failed.

Freeness is now three-valued: free, busy, or "nothing here can answer",
and the last one carries its reason.

On a remote pane freeness is the far shell's own OSC 133 prompt marks.
That is sound because the near shell cannot be at a prompt while the
connection owns its pty, so a prompt mark on such a pane can only have
come from the far side. Two things had to change for the daemon to be
able to say it. The reader suppresses relayed prompt marks so a
foreground program cannot engage the local line editor — right for the
editor, and precisely wrong here — so `ShellState` now keeps the mark's
own unsuppressed reading beside the editor's. And "not at a prompt" on a
remote pane means nothing until the far shell has proved it reports at
all, since the newest mark is otherwise the near shell's own "I started
`ssh`", which nothing will ever supersede; a latch records the first
prompt mark that arrives while the pane is remote, and is cleared on
every hop. `PaneProcs` carries all of this to the CLI in a new optional
`context` — remote target, whether this machine holds the pty, the
mark's reading, the latch — so the answer still costs one request per
poll and an older server, which omits the field, keeps today's
tree-only behaviour.

When the far host has no shell integration the honest answer is that
this machine cannot tell an idle remote prompt from a running remote
command. `wait` says so — exit 1, `status: unknown`, a `free_unknown`
string naming the host — after one poll of grace for a handshake still
in flight, and only when `free` was the only state that could still
answer, so `--until done,free` keeps waiting on `done`. Without that it
would hang forever on a wait with no `--timeout`. The `no-agent` timeout
hint now fires only for a caller who did not already pass `--until
free`, and the reason freeness never resolved is printed and put in the
JSON in its place.

Deliberately left alone: on a local pane the process tree still holds
the verdict. A prompt mark can only turn a busy tree into free — which
is what fixes a plain `ssh` pane on Windows, where the daemon has no way
to name the pane as remote — never the other way round, so no pane that
reads busy today can start reading free because an integration went
quiet. `free` therefore now means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is
free, and the reference says so. The handoff record is unchanged, so a
pane mid-`ssh` that survives an exec comes back reporting "cannot
determine" until the far side's next prompt rather than guessing.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:54:46 +08:00
l0ng-ai 6aa83b4bd9 chore(release): v26.9.2 v26.9.2 2026-09-10 11:50:47 +08:00
l0ng-ai 5ac26b5dd9 feat(cli): add capture --tail N (#841)
Every "how did the last command end?" query went through `| tail -n 5`,
which ships the whole grid down a pipe to throw most of it away — and on
Windows, where `capture` is just as useful, there is no `tail(1)` to pipe
to. `--tail N` keeps the last N lines of the answer instead.

It is a third independent choice beside `--scrollback` (how much of the
ring) and `--plain` (in what form), and it composes with both. The trim
runs last, after `--plain` has decided what a line is: a shell line the
pane wrapped over three rows is one line to the grid and three to a byte
counter, so `--plain --tail 1` hands back the whole of the last line
rather than its final row. The raw form counts the same way `tail` does,
splitting on the newline and leaving the CR of a CRLF attached to the
line it ended, so the bytes stay the pane's own. A trailing newline
terminates the last line rather than opening an empty one, which is the
difference between `--tail 1` answering the last line and answering
nothing.

`N` must be at least 1: a tail of zero lines would print an empty result
that reads exactly like the blank-pane ambiguity this issue is about, so
it is a usage error (exit 2) instead. `--json` reports the tail in `text`
but leaves `bytes` the size of the whole replay, so the pair still says
"this was trimmed" rather than "this came back short".

Left alone deliberately: the daemon still replays the entire ring on
every observe, so the saving here is the pipe, not the wire. Bounding
what crosses the wire means teaching `ClientMsg::Observe` a limit and
versioning the protocol for it, which is a much larger change than the
papercut warrants — and the default (newest-segment) form has always
received the whole ring and discarded most of it, so this adds no new
cost. The docs say so rather than implying otherwise.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:41:32 +08:00
l0ng-ai 0752110b54 fix(cli): stop a resize from emptying capture, and say when a replay renders blank (#841)
`tty7 capture %n` answered a live pane with zero bytes and exit 0, which
is byte-identical to a pane that had genuinely never printed. The report
guessed at the replay grid. It is not the grid — it is the segment the
grid was handed.

The daemon's replay ring splits on resize: `ReplayRing::resize` seals the
segment holding the output and pushes an empty one at the new geometry,
and `ReplayRing::replay` sends every segment it holds, empty tail
included. `RealBackend::capture` then kept "the newest segment" for the
default (non-`--scrollback`) form, and for any pane resized since it last
printed, the newest segment is that empty placeholder. A pane restored
from disk lands in the same state, because seeding the ring ends in a
resize too.

Measured end to end against a real daemon before the fix: a pane holding
954 bytes of scrollback answered `capture` and `capture --plain` with 0
bytes and exit 0 while `capture --scrollback` returned all 954. The fix
drops byte-less segments before choosing the newest one — they carry
nothing in either form, so `--scrollback` drops them too and both forms
describe the same bytes. The daemon still sends them: its trailing `Size`
is how an attaching client learns the pane's current geometry, and that
is not the CLI's to take away.

What this does not explain is the reporter's other half — that
`--scrollback` came back empty in the same episode. Filtering empty
segments cannot cause that, and neither could I reproduce it: 60 rounds
of `capture` against a pane spewing 4000 escape-laden lines produced no
empty result, and an 8 MiB ring (the cap) replayed whole in ~400 ms, well
inside the 300 ms per-frame settle window that was the other suspect. So
the silent-empty class is left distinguishable rather than declared
closed: `--json` now carries `bytes`, the size of the replay counted
before anything renders or trims it, and the one case where an empty
answer is not an empty replay — bytes in, no text out — says so in a line
on stderr. Zero bytes is a pane that printed nothing; bytes with no text
is a screen whose content did not survive the grid. Deliberately no retry
loop and no warning on a condition that has not been established.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:41:32 +08:00
l0ng-ai 87e9f4edd2 fix(switcher): spend a dismissing click on the dismissal
The switcher's scrim covers the whole window, the tile that opens it
included. Its mouse-down closed the switcher and then carried on down to
whatever sat beneath — for that tile, straight into `toggle_switcher`,
which reopened what had just closed. Clicking it a second time looked
like it did nothing.

Stop the press at the scrim. A click that dismisses is spent on the
dismissal and reaches nothing underneath it, so the tile toggles and no
control fires by accident on the way out.

Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa
2026-09-10 11:33:47 +08:00
l0ng-ai bd48a14260 fix(chrome): answer a hover on the workspace tile
The tile's only hover state was a fill the palette derives one step off
the surface, which on the rail is barely a change at all — and the name,
the monogram and the chevron each pinned `muted_foreground`, so the
button's own hover ink never reached them. The one control at the top of
the column said nothing when the pointer landed on it.

Move the ink and the hover onto the row holding the three children and
let them inherit, the way a group header does: the text steps up to full
strength. The fill stays, it is simply no longer the whole signal.

Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa
2026-09-10 11:33:47 +08:00
l0ng-ai 8a4f08e368 fix(ui): remember the focused pane as focus arrives, not at switch time (#843)
A tab's `last_focused` was written by one sample taken on the way out of
`activate`, and that sample asks which leaf holds focus at that instant.
By then focus is routinely somewhere else: the switcher's own search
input, a palette that just closed, the tab strip, the file tree
`activate` focuses itself, or a pane restored and never clicked. With no
leaf focused the write was skipped, the field kept a stale pane or the
`None` it was born with, and `focus_target` fell through
`leaf_matching_or_first` to the tab's first leaf. Two panes, click the
right one, switch away and back, land in the left one.

`watch_pane_focus` already subscribed to every pane's focus-in — it
existed to repaint the chrome that marks the focused pane — so the
record now rides on that callback instead of a new one. Focus-in is the
only moment that knows the answer without having to guess when to look,
and the subscription's cost and lifetime were already paid for. Which
tab gets the write is asked of the layout rather than assumed to be the
active one, so a pane dragged into another tab is remembered by the tab
holding it now; a leaf no tab holds is recorded nowhere. Connecting
slots are watched too, since a pane can be focused while it is still
coming up.

`remember_active_pane` stays. It never writes a wrong answer, only
sometimes none, and its callers want the field settled at a named moment
— before a pane is detached, before a tab is torn down — while the
layout can still answer.

Deliberately left alone: a restored tab still opens on its first leaf
until someone touches a pane. That is a different hole — persistence,
not a focus race — and closing it means giving a leaf an identity that
survives a restart. `SessionTab` has none, and an ordinal into the tree
is not one either, because `session_to_pane` drops leaves that cannot be
respawned and shifts every index after them. The report's third
suggestion, preferring a most-recently-active leaf recorded elsewhere,
was checked and dropped: `last_used` exists on `Tab` for the switcher's
MRU column and there is no per-leaf equivalent anywhere. No timestamp
was invented to create one, and with focus-in authoritative there is
nothing left for it to break a tie about.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:28:04 +08:00
l0ng-ai b559ae658d fix(daemon): clear the dead TUI's input modes on restore (#850)
A restored pane came back with the killed program's mouse reporting still on,
so every pointer move over it was typed into the new shell's line as an SGR
report and the line grew for as long as the pointer stayed there.

The chain is short and each link is deliberate. A snapshot is a raw byte
stream, not a rendered grid, and `scrollback.rs` says so in its own module doc.
`ReplayRing::seeded` puts those bytes into the restored pane's ring verbatim,
and the client hands them straight to its parser at `DaemonMsg::Snapshot`. So
the dead program's `?1002h` is not a description of a screen, it is an
instruction, and it is executed a second time against a shell that never asked
for it. There is nothing after it to undo it: `DaemonPane::kill` is a hangup
with no grace period, so the program never emitted its own `?1002l`, and the
snapshot was photographed before the kill anyway. `restore_preamble` already
exists to keep exactly this kind of leftover away from the incoming shell — it
resets the alternate screen, the cursor, autowrap and the graphic rendition —
and it simply stopped short of the modes that make the terminal talk back.

It now also turns off mouse reporting and its encodings, focus reporting,
bracketed paste, application cursor keys and the kitty keyboard flags.

Unconditionally, which was the choice worth making. The alternative was to fold
the snapshot's bytes through a mode tracker and clear only what the fold says is
still on, which is what #828 does for the neighbouring re-attach path. That
precision is essential there and buys nothing here: there the pane is alive and
re-sending a mode it had turned off would be wrong, whereas here the target
state is a constant. The incoming shell is brand new, it has not written a byte
when the preamble is appended, and it asked for none of these modes, so there is
nothing to preserve and no mode whose value has to be discovered. The risk is
not symmetric either. Switching off a mode that is already off is a no-op in
every emulator, so the blunt version cannot fail; a fold that misparses one
sequence leaves the mode on and the bug exactly as it is today, silently. The
blunt version also needs nothing from #828, so this can merge in either order.

Left alone on purpose: `?1007` alternate scroll, which a default terminal has
on, so clearing it would walk away from the default rather than back to it;
`?2026` synchronised update, which the client's processor already closes out
when a replayed frame ends inside one; and `stale_mode_resets` in the client,
whose conditional shape is right for the live pane at a prompt that it serves.
Scrolling regions and origin mode are a different failure with no report behind
them and are not touched.

The test that locked in the short reset list now locks in the long one, and
asserts the `?1007` omission so it stays deliberate. A second test runs the
real chain — a snapshot ending inside a full-screen program, `ReplayRing::seeded`,
the preamble — and asserts that the ring a client would replay has `l` as its
last word on every reporting mode. Both fail on the previous preamble.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:23:25 +08:00
hhdebb f812b5e76d fix(tabs): stop an untouched rename box from naming the tab (#849)
The box opens holding the tab's label as rendered, so it is never empty,
and `commit_rename` fires on `Blur` as readily as on Enter. Opening the
box and clicking away therefore stored that label as the tab's name — one
the user never typed, and one that means something different from the
title it was copied from: a name stops following the pane, so the tab
froze on whatever it happened to say at that moment.

Read the box against what it was seeded with. `rename_outcome` answers in
three states rather than two, so the one that has to keep working keeps
working: an emptied box still clears the name, which is the only way to
give a tab back to its pane.
2026-09-10 11:21:19 +08:00
hhdebb bb7078cfd0 fix(sidebar): let an agent's status dot sit outside its disc (#846)
`status_dot` places itself with negative offsets so that it overhangs the
avatar's edge — that overhang is what makes it read as a badge sitting on
the disc rather than a notch bitten out of it. But it is added as a child
of the element carrying `rounded_full`, so everything past the circle is
clipped along the arc and the badge comes back as a crescent.

Give the wrapper the positioning and move the disc down a level. The
radius then only ever clips the disc's own fill and mark, and the dot
becomes a sibling drawn after it, which settles the paint order too.

No test: the change is structural — which element carries the radius —
and the tests here cover the pure decisions behind the avatar
(`needs_edge`, `agent_status_label`) rather than its element tree.
2026-09-10 11:18:45 +08:00
l0ng-ai 2f500a8b5c fix(sidebar): put a row's cwd on the title's line, not under it (#851)
Outside a repo a sidebar row grew a second line for its working
directory, so a group of plain shells was a column of two-line rows
describing paths that mostly agree. The path now rides on the title's
own line: it takes what it needs up to half the line and the title
keeps the rest, the same split a group header makes with the branch
beside its heading. A row keeps its second line only for a branch.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-10 08:46:52 +08:00
l0ng-ai 894af9de3c fix(sidebar): lift a lone row's branch onto its group header too (#836)
A group whose rows share a branch already prints it once on the header
instead of once per row, but the rule asked for at least two rows. A
one-tab group was left with a bare heading over a two-line row, a shape
no other group in the column has, and the branch down there described
the same repo the heading above it already named.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 08:16:07 +08:00
l0ng-ai e320f70fb0 fix(terminal): encode the function keys and stop swallowing them (#834)
F1 through F12 produced no bytes at all. `functional_key` in
`src/terminal/input.rs` is the one place a named key becomes an escape
sequence, and it knew the cursor and editing keys and nothing else; the
`key_char` fallback below it cannot help, because a function key arrives
with no character, and `text_key_code` gives up the moment a key name is
longer than one character. Both encoders funnel through that function,
so the kitty path was equally blank. This was never a Windows or a
PowerShell problem — it was every platform, every shell — but it shows
up first on Windows because PSReadLine keeps CharacterSearch on F3,
HistorySearch on F8 and ClearHistory on Alt+F7, so the missing keys are
part of the shell's ordinary editing surface rather than something
exotic.

The table is `xterm-256color`'s, since that is what we advertise in
`$TERM`: `kf1`..`kf4` are the SS3 letters, `kf5`..`kf12` the numbered
tilde forms whose numbering starts at 15 and skips both 16 and 22.
Modified, they take the same `CSI 1;<mods>` and `CSI <n>;<mods>` shapes
the cursor keys already use, which reproduces `kf13` onwards exactly —
`kf13` is Shift+F1, `kf25` is Ctrl+F1. DECCKM is deliberately not
consulted: unlike `kcuu1`, `kf1` is SS3 under both `smkx` and `rmkx`.

It stops at F12 on purpose. In the entry we advertise there is no
capability left for a thirteenth key — `kf13` and up are already the
modified forms of F1..F8 — so sending the VT220 `\E[25~` for a physical
F13 would hand ncurses a sequence its own table reads back as Shift+F1.
Silence is the more honest answer, and F13+ keys are close to
nonexistent on the keyboards this bug was filed from.

Encoding alone would not have reached the shell, because two things
upstream were eating the keys first. The inline prompt editor had no arm
for a named key it does not bind, so F8 fell out of the bottom of
`handle_editor_key` and died on a `cx.notify()`; it now takes the same
route an unknown Ctrl chord takes, handing the line to the shell before
sending the key, which is what PSReadLine's history and character
searches need in order to act on it. And Find Next / Previous sit on F3
and Shift+F3 off macOS, where gpui matches the binding before the pane's
key handler ever runs; with no find bar open there is no match to step
to, so those listeners now give the keystroke back the way `EditorSave`
gives back Ctrl+S.

F11 keeps fullscreen. It is the chord Windows Terminal, GNOME Terminal
and konsole all use, no shell binds it, the modified forms still reach
the PTY, and it is one line of config away from being retired — but it
is now a stated choice with a test standing on it rather than an
accident. The reporter's second clause is explained rather than fixed:
`prompt_editor` is a real gate, and turning it off could not help while
the bytes did not exist, which is exactly what they saw.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 22:36:17 +08:00
l0ng-ai 3cf226f809 fix(sidebar): stop a long branch from eating a group's name
A header that carried its group's shared branch handed the overflow to
the name and the branch by flex shrink, which splits it in proportion to
what each item asked for — so the longer string took the smaller cut.
Next to `fix/rpc-proxy-and-error-classification` the heading came out as
`DEL…` while the branch still showed thirty characters, which is exactly
backwards: the name is what the group is, the branch only what it
happens to be sitting on.

The header now measures itself against its real chrome the way a row
already does. The branch takes what it wants up to half the line, the
name keeps the rest above a 40px floor, and each is elided into its own
share — the branch keeping both ends, so two branches off one prefix
still read apart. `header_name_avail` holds the split and has three
tests; the counts measurement a row and a header both need moved into
`counts_width`.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 22:13:33 +08:00
l0ng-ai d23bfb9ab9 revert(sidebar): paint the agent disc solid again
The resting tint read as a disabled tab, not as a quieter one: a column
of 16% discs looked like a list of agents that had been switched off,
and the brand hue is how the eye tells a Claude row from a Codex row
before it reads either title.

The disc goes back to a solid fill of the agent's brand on every row,
lit or not, with the mark in the agent's own ink. `tab_avatar` loses the
`lit` argument and the three call sites stop threading selection into
it; `legible_on`, which only existed to ink a glyph on a tinted disc,
goes with it. The rest of the sidebar hierarchy work stays.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 21:57:39 +08:00
l0ng-ai 47af66ff52 fix(chrome): keep the strip's two tiles while the detail panel is open
With the panel open those tiles stand in the band above it, over the
panel's own header — and that header's tab tiles are painted whenever the
panel is, so a band that grew two buttons under the pointer read as a
glitch beside them. macOS already struck this bargain when it moved the
tiles into the panel's title bar: once the panel is open they are part of
its chrome, not part of the strip's. Windows kept them hover-gated.

Position is unchanged; only the visibility gate is.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-09 20:30:09 +08:00
l0ng-ai 400ac032c8 Merge pull request #833 from l0ng-ai/feat/right-panel-chrome
feat(panel): keep the panel's chrome tiles and underline the current tab
2026-09-09 19:33:20 +08:00
l0ng-ai 4c593ef4a4 fix(panel): let the tab underline reach the rule that closes the row
The bar is pinned to the bottom of the tile's own box, which only meets
the hairline when that box is as tall as the row. On macOS it is; in
`panel_title`, where Windows and Linux draw the same tiles, the wrapper
around them is only as tall as a glyph, so the bar floated a few pixels
above the line. Give that wrapper the row's height.

Keep `occlude` on the tile rather than on the full-height frame the bar
hangs off: over the whole row it would take the few pixels above and
below each glyph out of the title bar's drag region and hand them to
nothing.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 19:26:35 +08:00
l0ng-ai 20d3427030 feat(panel): keep the panel's chrome tiles and underline the current tab
The right panel's own title bar hid its two trailing tiles until the
pointer entered it, while the three tab tiles beside them were always
drawn — a row that grew two buttons on hover. Paint them with the rest.

Rule the row off from the content with the lighter of the two hairline
tiers, the one the panel's left edge already uses, and leave 8px under it
so the first row of content is not sitting on the line. The other
platforms draw this line on the title row, which keeps that distance with
its own text.

Say which tab is current with a bar under the glyph rather than with a
fill. The fill was the same grey the hover state paints, so the lit tab
and the tile under the pointer read as the same thing.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 19:18:21 +08:00
l0ng-ai 6c7863ac1f Merge pull request #829 from l0ng-ai/refactor/drop-conversation-outline
refactor(panel): drop the agent conversation outline
2026-09-09 18:40:22 +08:00
l0ng-ai b90b903527 fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
A pane that came back from a workspace switch came back empty. All that
was left was the shell banner and the `claude --resume … --fork-session`
line the pane was born with; the agent above it was still running and
still painting, but only in fragments, into an otherwise blank screen.
Switching tabs did nothing. Resizing the window fixed it, and only for
the tab that had focus, which had to be repeated tab by tab.

Nothing is lost on the wire. The replay arrives whole — every segment,
every byte — and is applied. What happens is that the client throws the
result away three frames later.

`replay_state` closes an attach by reporting the pane's shell state, and
a client that hears `active && at_prompt` scrubs the TUI modes it finds
in its grid, the alternate screen first (`stale_mode_resets`). For a
live report that is sound: a shell that is prompting cannot have a
full-screen program underneath it, so a `?1049h` still set in the grid
is residue from one that died without its `?1049l` — an `ssh` dropped
mid-`vim` — and sending the `?1049l` is the repair.

For a replayed report it is not sound. The alternate screen the scrub
finds there is the one the ring has just rebuilt, and `?1049l` does not
undo a stale mode: it swaps a live screen away and puts the primary
screen back in its place, which for an agent pane is precisely the
banner and the launch command. The program never learns any of this. It
goes on sending differential updates — only the cells it believes
changed — into a grid that no longer holds what those updates are
differences from, which is why the status line came back as fragments
with the middle blank. And a resize is the one thing that repairs it,
because a resize is the one thing that reaches the child: new geometry,
`SIGWINCH`, a full repaint. A tab switch reports geometry the daemon
already has, so it repairs nothing.

The stale claim comes from `st.shell.at_prompt`, which is only ever as
fresh as the last OSC 133 mark the pane produced. A shell that printed
its prompt (`133;B`) and then handed the terminal to a program that
sends no `133;C` of its own leaves that flag set for as long as the
program runs. The live path already declines to believe such a mark: the
pane reader drops `at_prompt` from any prompt mark that arrives while a
foreground command owns the pty. The replay was the one place that
re-asserted the stored value with no check at all.

It now asks the pty the same question, once, before it takes the state
lock, and reports a prompt only when nothing but the shell owns the
terminal. That keeps the scrub's purpose intact — when the shell really
is prompting there is no foreground command, the report goes out
unchanged, and a genuinely stranded alternate screen still heals on
reattach — while removing the case where the report contradicts a
program that is still there.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:20:14 +08:00
l0ng-ai 425bf32e01 fix(tree-sync): spend a typed workspace name only on the create it rode with (#716)
A name typed into the create form cannot be sent as a rename: the workspace does
not exist on the machine yet, so the rename is answered `NotFound` and dropped,
and the create that follows names the workspace whatever `fresh_workspace_name`
rolled. That is #618, and the fix was to park the name on the window and have
the pull spend it — offering it to the create, and, if the machine came back
saying something else, sending it afterwards as the rename it had become. #604
then wired that answer straight to the switcher chip.

The trouble is that "the machine came back saying something else" is also what
walking into somebody else's workspace looks like. `settle_chosen_name` had two
facts to work with — a parked string and the name the machine answered with —
and neither of them says which workspace the name was meant for, or whether
anything was created at all. So it fired the rename at whatever workspace the
window had landed on. In #716 that was a workspace already holding nineteen
panes on another machine, and it came back named after the arriving client's
login. The report reads as one failure; it was two, and this is the second.

The parked name now carries the workspace it was typed for, and the pull now
says how the workspace got there. `pull_or_create` and `pull_workspace` answer
`Arrival::Created` when a create ran and `Arrival::Adopted` when the tree simply
had the workspace already, and the name is spent only when both agree: same
workspace, and a create to ride along with. Losing the create race still counts
as `Created` — a create did run for that workspace a moment ago, it just was not
this one, and its rolled codename is precisely what #618 exists to beat.

A name that does not match is left parked rather than dropped. Opening a
workspace runs two pulls at once, `start_prime`'s and `hydrate`'s, and only one
of them creates; taking the name on the adopting one would let the loser of that
race swallow it before the winner could spend it, which is the regression this
is trying not to reintroduce. Nothing leaks: `forget` drops the whole window
state when the window leaves the workspace.

`a_workspace_the_machine_already_had_still_takes_the_typed_name` is reshaped
rather than deleted. What it was really pinning is the create that answers under
another name, and that is still asserted, under a name that says so. The half it
asserted wrongly — that an adopted workspace takes the name too — is now pinned
the other way, once through `finish_prime` and once through `settle_hydration`,
which is the path the report actually came in through.

Left alone: the daemon still executes a `WorkspaceRename` from any client
without asking whether that client has ever pulled the tree. Like the tab-tree
half of #716, this fix is client-side, and an older build can still do it to a
machine running current main.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:17:34 +08:00
l0ng-ai 29312d5bb8 fix(procinfo): stop the listening-port probe failing silently (#731)
The Ports section says nothing when a pane has no listeners, and it said
exactly the same thing when the code that looks for listeners never ran.
#731 is a report from inside that gap: a Go service started with `go run
main.go` on macOS serves requests, and tty7 shows no port.

The reported shape itself holds up. `snapshot()` walks the whole descendant
tree from the pane's shell, `go run`'s compiled binary sits at depth 2, and
the `lsof` invocation is the one that finds it. What did not hold up is
everything around that.

The walk stopped at 64 processes, and it is depth-first over children in
ascending pid order. A shell whose earlier children brought a crowd — a
build, a container runtime, an agent's worker pool — could spend the whole
budget before the traversal reached the newest child, and the newest child,
highest pid and visited last, is precisely the server someone started ten
seconds ago. The walk now runs to a far larger bound and the probe is asked
about all of it; only the list handed to the panel is cut back to 64 rows.

Every way the probe can fail arrived as the same empty vector. `lsof`
missing from the daemon's PATH read as "nothing is listening" — and the
daemon's PATH is not the shell's, while macOS keeps `lsof` in /usr/sbin,
the sort of entry a hand-written `export PATH=...` drops. So did a probe
that hung: `Command::output` has no deadline and this runs on the thread
answering `QueryProcs`, so one `lsof` wedged on a dead mount takes the whole
pane's process list with it, permanently. The probe now falls back to the
absolute paths, is bounded at three seconds, and says which of those
happened.

A server under `sudo` is visible as a process and invisible as a socket:
`lsof` running as this user cannot read another user's fds. The walk now
carries each process's effective uid, and a tree holding someone else's
process says so rather than claiming the pane is quiet.

`PaneProcs` grew a `probe` verdict, `serde(default)` so an older
`tty7-server` at the far end of a remote workspace still parses and its
silence still reads as a complete answer. The panel spends it on the one
muted line where it used to write "None", and `tty7 procs` — the command
the issue asks reporters to run — prints a note under the empty PORTS
table. No banner and no new colour: an honest empty state, not a warning.

Deliberately left alone: `lsof`'s exit status. It returns 1 for a pid it
could not locate, and a pane's tree loses processes between the walk and the
probe as a matter of course, so reading that as a broken probe would put a
doubt on screen every time a command finished. A non-zero exit that also
found nothing gets a debug log line and no more. The Windows path is
untouched beyond its new return type — `GetExtendedTcpTable` has no tool to
be missing and no subprocess to hang.

I could not reproduce #731, and none of these is proven to be the
reporter's bug. Each is a way the panel could be silently wrong, and the
verdict is what will make the next report say which one.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:13:51 +08:00