Commit Graph
1259 Commits
Author SHA1 Message Date
l0ng-ai 1f189e2ee0 fix(window): keep the title bar row in fullscreen, drop only its window buttons
Hiding the whole title bar took the tab strip with it: with tabs on top
every chip, the New Tab tile and the panel/menu tiles vanished in
fullscreen, the docked document header (drawn only over the spanning bar)
disappeared, and the strip's drop band kept claiming a row that was now
terminal.

What is actually dead in fullscreen is minimize/maximize/close. The row now
stays; in fullscreen off macOS the strip goes into a plain row of the same
geometry instead of `TitleBar`, which always draws those buttons, and the
room reserved for them (strip width, chrome band over the panel, document
header padding) comes back. The notice text says the window buttons are
hidden rather than the title bar, and the keymap test whose premise was the
bar disappearing is replaced by one pinning the controls width.
2026-09-14 18:35:15 +08:00
l0ng-ai 237cea849c Merge remote-tracking branch 'origin/main' into fix/hide-the-title-bar-in-fullscreen 2026-09-14 18:27:20 +08:00
l0ng-ai 66c42e48b7 Merge pull request #873 from WhiteDG/feat/qodercli
feat(agents): add Qoder CLI integration
2026-09-11 15:00:07 +08:00
l0ng-ai 62cd72922a Merge pull request #865 from hhdebb/fix/strip-the-agent-status-mark
fix(tabs): take the agent's status mark off the title it writes (#847)
2026-09-11 14:43:40 +08:00
l0ng-ai d050b63151 Merge pull request #872 from hhdebb/fix/report-the-terminal-to-assistive-clients
fix(terminal): report the terminal as the focused element
2026-09-11 14:43:36 +08:00
l0ng-ai 63b3951ef3 fix(agents): detect Qoder through the binary its docs tell you to run
The npm package installs two binaries. `qoder` is a dispatcher that routes
to the CLI for a bare invocation, a flag, or a prompt, and only hands off to
the IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists — and it
is the one the documentation tells people to run. Both are
`#!/usr/bin/env node` scripts, so what the pty carries is node plus the path
to the shim; the dispatcher's child, where `qodercli` appears on the path,
is not the process group leader and is never read. Detecting `qodercli`
alone missed every session started the documented way.

An IDE launch now wears the CLI's avatar for as long as the launcher takes
to exit, which is the cost of covering the common case.

Also: `--session-id` restores a session rather than naming a new one, so say
that where the flag is stripped, and assert Qoder has no `Notification` seat
instead of putting a payload through a hook map that has none.

Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh
2026-09-11 14:38:40 +08:00
hhdebb 2f63a01f7b fix(terminal): report the terminal as the focused element
A terminal draws its own glyphs, so nothing outside the window can read
what is on screen. That much is a terminal being a terminal. What is not
is that the window reports no focused element at all.

gpui sets accessibility focus in exactly one place: a `div` that tracks a
focus handle and has an a11y node of its own. The terminal surface tracks
the focus handle and never asks for a role, so it has no node, so
`set_focus` is never reached — and a client asking the window what has
focus is handed the window.

Measured on Windows 11 26200 with a UI Automation probe: the window
answers with `WindowPattern` and nothing else, publishes zero
descendants, and `FocusedElement` is the top-level window, supporting
neither `ValuePattern` nor `TextPattern`. A screen reader has nothing to
say about a tty7 window for the same reason.

It reaches past screen readers. A dictation tool pastes its transcript
and then asks the focused element what it now says, to check the text
arrived. Against tty7 it gets no element to ask, concludes the paste
failed, and hands the transcript back for the user to paste by hand —
while the bytes it sent are already in the pty and the text is on screen.
That is what led here.

The fix is the surface asking for a role: it gets a node, and focus lands
on it. `MultilineTextInput` rather than `Terminal` because `Terminal`
maps to a document that reports itself as not editable, and "is this
something text can be put into" is the question these clients are
actually asking. The node carries no text of its own yet — reading the
grid out is a separate change with a cost per frame, and this one has
none: gpui builds the a11y tree only once something attaches to it, so a
window nobody is inspecting still builds nothing.

The path this fixes is platform-independent; it was verified on Windows,
where the dictation tool that surfaced it runs.
2026-09-11 11:09:36 +08:00
White 9e338b1d60 feat(agents): add Qoder CLI integration
Hook events map Qoder's lifecycle to tty7's state machine: session start,
prompt submit, permission requests, MCP tool elicitation (an authorized MCP
tool can still pause for user input mid-call), tool completion, stop, and
session end. Compaction events are filtered out—Qoder emits a session-start
after compacting the active turn, which would reset the status line to Idle
without this filter, even though the turn is still running.

Settings path resolution respects QODER_CONFIG_DIR for local installs,
falling back to ~/.qoder/settings.json. Remote targets ignore the override
(a local env var must not redirect remote hooks).

Session commands support --resume and --fork-session. The resume command
strips conflicting flags (--resume, -r, --continue, -c, --session-id,
--worktree, --fork-session) from the original launch argv before appending
the new session id. The -w/--cwd flags survive (Qoder's -w means --cwd,
not --worktree). Both commands require session persistence: when
--no-session-persistence is present, there is no saved conversation to
reopen, so the commands return None.

Tests cover compaction preservation, MCP elicitation state transitions,
QODER_CONFIG_DIR's effect on the hook lifecycle (multi-case isolation),
resume/fork command generation, worktree flag handling, and persistence
requirements.

Localization complete for en/ja/zh. Icon embedded, search keywords wired.
2026-09-11 10:30:19 +08:00
hhdebb 56238bf3bb fix(tabs): take the agent's status mark off the title it writes (#847)
Agents animate in the terminal title while they work, and they do not
agree on an alphabet: Claude Code cycles the quadrant circles and rests
on an asterisk, others step through the braille frames, some write
nothing at all. Rendered as they arrive, a column of tabs carries a mark
in front of some rows and not others, in three vocabularies — while the
row already says what the agent is doing, in one, with its status dot.

So the mark comes off, for everyone, with no setting. A switch would not
settle this: nobody opens settings to decide how a spinner is drawn, and
a default-off toggle buys two render paths to maintain forever in order
to answer a question that has one right answer per person and no way for
the app to know which.

**A known alphabet, not a shape.** The obvious rule — a leading character
that is non-ASCII and above some code point, followed by a space — matches
by shape, and `🔥 build`, or `📁 ~/repo` written by somebody's shell
integration, fits it exactly and quietly loses its first character with no
way to ask for it back and no clue as to what took it. Matching marks we
have actually seen costs the same and cannot do that: the braille block,
the four quadrant circles, and Claude Code's resting asterisk. When an
agent invents a mark that is not on the list, the failure is today's
behaviour — the mark stays — which is the safe direction to fail in, and
adding it is a line in the table.

Two things the rule insists on, both to keep it from reaching past what
it is for. A mark only counts with whitespace behind it, so `✳fixing` is
a word that starts with a character rather than a mark in front of one.
And a title that is *only* a mark keeps it: taking it would leave an empty
string, and an empty title is not a tab called nothing, it is a tab that
falls back to its number — less than the mark was saying.

It happens in `TabView::label`, which is where a title becomes a label, so
the strip, the sidebar, the switcher and the rename box's prefill all
agree without being told separately — and, because `label` reaches a
given name before it reaches the title, a tab somebody deliberately
called `✳ release` keeps what they called it. That ordering is the only
thing standing between a user's name and a rename behind their back, so
there is a test on it rather than a comment. Three existing tests carried
`✳` in their fixtures and now expect it gone. The one in `switcher.rs` was
asserting that a tab in another window is named the way a local one would
be, which is still exactly what it asserts; the one in `tty7-cli` is the
table getting this for free, since `tab_label` reads `label` and so
`tty7 ls` says what the tab strip says without either being told about the
other. The daemon's fixtures keep their marks on purpose: a title is stored
as the terminal wrote it, and only what turns one into a label takes
anything off.

This leaves the row with nothing moving in it, which is a real loss and is
answered separately: `AgentStatus::dot_rgb` returns three flat colours,
and a `Working` dot that breathes says the same thing in the vocabulary
the row already speaks.
2026-09-11 01:10:21 +08:00
hhdebb d1f224f634 fix(window): hide the title bar in fullscreen where it is the app's to draw
On Windows and Linux a fullscreen window has no caption. The platform asks
what is under the pointer through `WM_NCHITTEST`, gpui answers from the
window control hitboxes the frame registered, and fullscreen clears
`WS_CAPTION` — there is nothing left to answer with. Measured on a
fullscreen tty7: `GetWindowLong` reports `WS_CAPTION` clear, and every
point along the top of the window comes back `HTCLIENT`, where the same
window a moment earlier answered `HTCAPTION`, `HTMINBUTTON` and `HTCLOSE`.

The bar was drawn anyway. `WindowControls` renders minimize, maximize and
close whenever the target is not macOS, without asking whether the window
is fullscreen, so all three sat there taking hover styling — gpui's own
dispatch reaches them fine — and doing nothing at all when clicked.
Dragging the bar did nothing either.

So on those two the bar goes. It is the app's own chrome there: a caption
to move the window by and the controls at its end, none of which a
fullscreen window has. Drawing chrome that cannot work is worse than
drawing none.

Not on macOS, and the reason is not that the bug is milder there — it is
that the premise does not hold at all. `WindowControls` draws none of the
three on macOS; the ones that go dead elsewhere are the system's traffic
lights, and the system hides them itself. What that bar does in fullscreen
is hold the band the system reserves: the traffic lights land on it when
the menu bar is revealed, and so does the translucent strip drawn under
the menu bar. Take the bar away and that strip lands on the terminal and
covers its first row instead — measured, and the difference is exactly
`TITLE_BAR_HEIGHT`. Fullscreen belongs to the system on macOS, and the bar
is part of how the system dresses the window rather than something broken.

Nothing on the bar becomes unreachable where it goes. Its controls are
actions first, dispatched from the window's root rather than from the bar,
and each has a chord or a seat in the palette, which has one;
`what_the_title_bar_offers_is_reachable_without_it` is that in a test.
Worth noting for anyone reading it: `ToggleTabSidebar` ships with no
chord, so in fullscreen the palette is how it is reached.

Entering says how to leave, because entering is the instant the bar
disappears — so only where it does, and only through the action: a window
that starts fullscreen because the setting says so is not a surprise
anybody needs explaining, and `startup_mode` is untouched by the toggle
either way. The chord comes from the keymap rather than from a string, so
it reads `F11` or whatever it was rebound to.

The notice carries an id, which is what keeps a held-down `F11` to one
notice rather than a column of identical ones: pushing under an id already
on screen replaces that one. Leaving through the action takes it back as
well. Leaving some other way lets it time out instead — a second or two of
a stale notice, which is not worth a per-frame watch on a state that lies:
`toggle_fullscreen` is spawned onto the executor on every backend, so
`is_fullscreen` still reports the old value when the action returns, and a
render-time test for "not fullscreen now" can take the notice back before
it has been seen.

Verified on Windows 11 26200, and on macOS 26.5.2 by a second pair of
hands: the macOS half of this is the reason the change is not applied
there. Linux is reasoned about rather than measured — it draws its own
chrome the way Windows does, and the same `WM_NCHITTEST`-shaped question
is answered through gpui's window control hitboxes.
2026-09-10 20:40:26 +08:00
l0ng-ai a60d992c99 Merge pull request #862 from l0ng-ai/feat/remote-latency-row
feat(ui): say how far away a remote pane's shell is
2026-09-10 18:31:45 +08:00
l0ng-ai 42c3ca6012 Merge pull request #861 from ayamir/fix/update-github-rate-limit-error
fix(update): explain GitHub API rate limits
2026-09-10 18:31:41 +08:00
l0ng-ai 906dccc378 Merge pull request #863 from l0ng-ai/chore/issue-forms
chore(issues): split the issue form into bug and idea
2026-09-10 18:21:56 +08:00
l0ng-ai ff852ee18d Merge pull request #859 from l0ng-ai/fix/file-tree-rail
fix(files): put the tree on the panel's own left rail
2026-09-10 18:21:07 +08:00
l0ng-ai 159f00f4c1 fix(update): read a 429 as a rate limit too
GitHub's REST API answers a spent quota with 403 or 429 depending on the
endpoint and the era, and both carry the same x-ratelimit headers. Only
the 403 spelling reached the retry advice, so a 429 told the reader the
quota was gone without saying when it comes back.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 18:20:48 +08:00
l0ng-ai ab26166f96 fix(review): keep pane_procs's doc on pane_procs, round rtt before the unit
The new `link_rtt` landed between `pane_procs`'s doc comment and
`pane_procs` itself, so the comment about walking pane process trees
documented the latency probe instead.

`format_rtt` also compared the unrounded milliseconds against 1000, so a
999.6 ms round trip printed as "1000 ms" — a millisecond reading past
the range the millisecond branch exists to cover. Round first, then pick
the unit.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 18:19:39 +08:00
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 ac36d4a4fa feat(ui): say how far away a remote pane's shell is
The Session table named the machine a pane's shell was on but never the
distance to it. A remote workspace gone slow looked exactly like one that
had not, and the only way to tell them apart was to leave tty7 and ping
the box by hand — with nothing to say whether tty7's own link was the one
that was slow.

Time the control link's `Ping` and put the last measurement on a `latency`
row, drawn only where there is a network between here and the shell. Every
ping that comes back feeds it, the keepalive's included, so a link being
kept alive already carries a number before anyone asks for one. Nothing
else is timed: every other request does work on the far side, so its round
trip measures that work rather than the link, and a `ReadFile` of a large
file would read as a network seconds slow.

The poll rides the Info panel's existing process-and-port round, and only
while the panel is open — that round also runs with the panel shut,
watching for ports to forward, and a round trip for a row nobody can see
is the far end's time spent on nothing. A link that drops keeps its last
measurement rather than blanking: a dropped link is exactly when someone
is reading the row to work out why a pane stopped answering.

Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt
2026-09-10 18:06:21 +08:00
ayamir 168f76d5a7 fix(update): explain GitHub API rate limits 2026-09-10 16:57:32 +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 a62efad35c fix(files): put the tree on the panel's own left rail
Every list in the right panel lays its column out a ROW_INSET short of
CONTENT_INSET and has each row pad itself back out, so a row's text lands
on the 12px rail and its hover and selection fill bleeds past it to 8. The
file tree ran its own pair of numbers instead: a px_1() column and a 6px
row inset, which put a depth-0 name at 10 and let the fill reach 4.

Two of those disagreements are visible. The tree sits directly under the
panel's search field, so the root row's folder glyph and the search
magnifier are two adjacent left edges 2px out of line. And a selected row
runs nearly edge to edge where the same row under Info or Source Control
stops 8px short.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 15:03:27 +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