Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/pane-mode-mirror-and-renderer-suggestion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@subshell-ai/plugin-claude-code": minor
"@internal/server": minor
"@internal/node": minor
---

Browser panes now learn the input modes the app inside them already owns, and the Claude Code classic-renderer switch is discoverable.

A pane whose application had already enabled alt screen or mouse reporting before you attached replayed a capture carrying none of that: the browser terminal could not know the app owned the mouse, so every wheel notch became a single arrow key through the socket ("slow scroll, no scrollbar"). The pane capture now leads with the escape sequences that announce the pane's live mode flags, measured against tmux 3.7c, so a fresh attach scrolls like a terminal on the machine: at the app's own speed. Vim, htop, and any mouse-reporting harness get this; apps that keep the plain screen keep the native scrollbar and scrollback untouched, and every server/agent version mix degrades to the old behavior rather than breaking.

The Claude Code preset editor now suggests `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, whose description names why you would set it: the classic renderer appends to scrollback, so the pane shows a real scrollbar. It is a per-preset choice, not a default; Claude Code's newer renderer keeps its no-flicker alt screen unless a preset says otherwise.
9 changes: 6 additions & 3 deletions apps/server/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,18 @@ remote pane, so nothing downstream of the attach branches on where a pane runs.
A garbled live terminal is diagnosed from the journal first: the
`geometry` and `painted` lines under `journalctl --user -u
subshell-server.service | grep "ws attach"` say which bundle is talking
and what the pane did before the capture. Three invariants on that path
and what the pane did before the capture. Four invariants on that path
are load-bearing and easy to regress: capture-text LF normalization
(`ws/capture-text.ts`; the live tail must NOT be normalized), the
gap-free OVERLAPPING join (a skipped byte desynchronizes a
diff-rendering TUI permanently; the overlap's visible transient is
deliberate damage control, and the zero-overlap "quiet join" was tried
and rolled back), and the replay's MISSING trailing terminator plus the
and rolled back), the replay's MISSING trailing terminator plus the
absolute-cursor restore (without it, every later relative-positioned
frame lands on the wrong rows). `SUBSHELL_ATTACH_DEBUG=1` dumps real
frame lands on the wrong rows), and the mode statement every capture
LEADS with (the pane app's alt-screen/mouse DECSETs, authoritative h/l
per live tmux flags; `""` means UNKNOWN, never "all off";
`TmuxRunner.paneModePreamble`). `SUBSHELL_ATTACH_DEBUG=1` dumps real
screen contents to world-readable files: off by default, never swept.

**Read `apps/server/api/docs/ws-attach.md` first: the full invariants,
Expand Down
22 changes: 21 additions & 1 deletion apps/server/api/docs/ws-attach.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ attach, both under `journalctl --user -u subshell-server.service | grep "ws atta
grid as the viewer found it vs. the exact bytes sent. **Off by default: the
dumps are real screen contents, which can include secrets.**

Three invariants on that path are load-bearing and easy to regress:
Four invariants on that path are load-bearing and easy to regress:

- Capture text (`replay`, pane-poll deltas) goes through
`ws/capture-text.ts`; `capture-pane -p` emits **bare LFs**, and a bare LF
Expand Down Expand Up @@ -124,6 +124,26 @@ Three invariants on that path are load-bearing and easy to regress:
off by whatever sat between the pane's cursor row and the bottom of the
grid (up to a full screen), the 2026-09-23 "prompt at the top, typing
off-screen" report.
- The capture result **leads with a mirrored mode statement**
(`@internal/pane-runtime` `TmuxRunner.paneModePreamble`, prepended by
`capturePane`): all five alt-screen/mouse DECSETs (`?1049`, `?1000`,
`?1002`, `?1003`, `?1006`) in their `h` or `l` form per the pane app's
live tmux flags, read via `display-message`. `capture-pane` re-emits no
DECSET and the live join starts after the app announced them, so without
the mirror the client's xterm never learns the app owns the mouse and
every wheel notch fires xterm's passive-wheel fallback, one arrow key
through the socket (the 2026-09-27 "slow scroll, no scrollbar" report).
The `l` forms matter: the pane-poll fallback re-sends whole captures as
its only channel, and only a full statement can un-announce a mode the
app dropped mid-session. They ride the front of the `replay` frame: no
LF, no 2026 marker, so the capture-text pipeline lets them through and
the byte join is untouched, because they are server-injected knowledge,
not pane output. Do NOT "restore" a capture to pure `capture-pane`
bytes; that is exactly the hole they fill. A `""` preamble means
UNKNOWN, not "all off": an old agent binary, a wedged tmux (the read
fails fast, 2.5 s), or a tmux that could not answer a flag all stay
silent rather than inventing an OFF that a poll re-send could deliver as
a false clearing of live-learned state.

## Node presence is an announcement too (moved from AGENTS.md "Architecture")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1571,20 +1571,28 @@ describe("reconcile partition — agent rows (spec §6.3)", () => {

describe("#preview — local rows read through the cache (cross-consumer dedupe)", () => {
/**
* A tmux stub that answers every `capture-pane` with the same two rows and
* counts its invocations by growing a file. Counting through a file rather
* than a closure is what makes the assertion honest: every capture is a
* fresh child process, and the one that captured N times is the one whose
* counter says N, not the one whose JS scope thinks so.
* A tmux stub that answers every call with the same two rows and counts
* `capture-pane` invocations by growing a file. Counting through a file
* rather than a closure is what makes the assertion honest: every capture
* is a fresh child process, and the one that captured N times is the one
* whose counter says N, not the one whose JS scope thinks so.
*
* The counter guards on `capture-pane` because since the mode mirror
* (2026-09-27) every `capturePane` call pays TWO tmux invocations: the
* flag read that leads the grid with the mode statement, and the capture.
* The dedupe this test pins is about the CAPTURE, so that is what gets
* counted; counting every call would report the mode read as a cache miss.
*/
function captureStub(): { stub: string; captures: () => number; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "subshell-preview-stub-"));
const counter = join(dir, "count");
writeFileSync(counter, "");
const stub = join(dir, "tmux-stub");
writeFileSync(stub, `#!/bin/sh\nprintf x >> "${counter}"\necho "screen line 1"\necho "screen line 2"\n`, {
mode: 0o755,
});
writeFileSync(
stub,
`#!/bin/sh\ncase "$*" in\n *capture-pane*) printf x >> "${counter}" ;;\nesac\necho "screen line 1"\necho "screen line 2"\n`,
{ mode: 0o755 },
);
return {
stub,
captures: () => readFileSync(counter, "utf8").length,
Expand Down
33 changes: 33 additions & 0 deletions apps/server/web/src/lib/__tests__/ansi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ describe("parseAnsi", () => {
expect(parseAnsi("[INFO] ready [1]")).toEqual([[{ text: "[INFO] ready [1]" }]]);
});

/**
* Every capture has led with the pane's input-mode statement since
* 2026-09-27 (pane-runtime's `paneModePreamble`): private-parameter CSIs
* (`?1049h` class) that a `[0-9;]`-only grammar renders as visible junk
* at the head of every preview card. This grammar is the regression fix.
*/
const MODE_STATEMENT = "\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003h\x1b[?1006h";

it("consumes the capture's leading mode statement", () => {
expect(parseAnsi(`${MODE_STATEMENT}grid line`)).toEqual([[{ text: "grid line" }]]);
});

it("still applies SGR that follows the statement", () => {
const [line] = parseAnsi(`${MODE_STATEMENT}${sgr("1")}bold${sgr("0")} plain`);
expect(line).toEqual([{ text: "bold", bold: true }, { text: " plain" }]);
});

it("a private-prefixed CSI ending in m neither styles nor resets", () => {
// DEC private sequences can share the `m` final byte; only plain SGR
// may change styling. Bold must SURVIVE the `?1`: without the
// private gate the sequence parses as `parseInt("?1") || 0` = SGR 0
// and resets, an unstyled version of this case could not tell.
const [line] = parseAnsi(`${sgr("1")}a${sgr("?1")}b`);
expect(line).toEqual([
{ text: "a", bold: true },
{ text: "b", bold: true },
]);
});

it("consumes cursor addressing too, though capture -e never emits it", () => {
expect(parseAnsi("a\x1b[34;1Hb")).toEqual([[{ text: "a" }, { text: "b" }]]);
});

it("splits a line into styled runs", () => {
const [line] = parseAnsi(`plain ${sgr("31")}red${sgr("0")} plain`);
expect(line).toEqual([{ text: "plain " }, { text: "red", color: "#cd3131" }, { text: " plain" }]);
Expand Down
40 changes: 27 additions & 13 deletions apps/server/web/src/lib/ansi.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/**
* Minimal ANSI SGR parser for terminal previews.
*
* Scoped deliberately to SGR (colour and attribute) sequences, because that
* is all the server sends: previews come from `tmux capture-pane -p -e`,
* which renders the pane's *current screen* and emits styling only — never
* cursor movement, scroll regions or erases. A general terminal emulator
* would be the wrong tool here; xterm already fills that role on the pages
* that need a real terminal, at a cost (a socket and a WebGL context each)
* that a wall of preview cards cannot pay.
* Previews come from `tmux capture-pane -p -e`, which renders the pane's
* *current screen* in styling; since 2026-09-27 the capture result also
* LEADS with the pane's input-mode statement (`?1049h`-class private
* sequences, pane-runtime's `paneModePreamble`); still no cursor movement,
* scroll regions or erases, but NOT styling-only. So this parser applies
* SGR and CONSUMES everything else: any CSI it failed to recognize would
* surface as visible junk in every card, which is why the grammar is the
* full ECMA-48 form and not just `[0-9;]` parameters. A general
* terminal emulator would still be the wrong tool here; xterm already
* fills that role on the pages that need a real terminal, at a cost (a
* socket and a WebGL context each) that a wall of preview cards cannot pay.
*/

/** One run of text sharing the same styling. */
Expand Down Expand Up @@ -194,11 +198,17 @@ function spanFrom(state: SgrState, text: string): AnsiSpan {
}

/**
* Matches any CSI sequence. Only the SGR ones (final byte `m`) change
* styling; the rest are dropped so their bytes never reach the page.
* Matches any CSI sequence in its full ECMA-48 grammar: parameter bytes
* (including the private-range `?` through `/` starts and `:`
* sub-separators), intermediate bytes, final byte. Only plain SGR (final
* `m`, no private start, no intermediates) changes styling; everything
* else is CONSUMED so its bytes never reach the page, including the mode
* statement every capture now leads with, whose `?1049h`-class sequences
* a `[0-9;]*`-only grammar would let through as visible junk (the
* 2026-09-27 regression this grammar closes).
*/
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the sequence being matched, not a stray byte
const CSI = /\x1b\[([0-9;]*)([a-zA-Z])/g;
const CSI = /\x1b\[([0-9:;<=>?]*)([ -/]*)([@-~])/g;

/**
* Parses a captured screen into styled spans, one array per line.
Expand All @@ -216,10 +226,14 @@ export function parseAnsi(screen: string): AnsiSpan[][] {
let match = CSI.exec(line);
while (match !== null) {
if (match.index > last) spans.push(spanFrom(state, line.slice(last, match.index)));
if (match[2] === "m") {
const params = match[1];
// Private (`?`, `<`, `=`, `>`) and intermediates-disambiguated CSIs
// share `m` final bytes with SGR (DECSTR `?…$p` etc. never reach
// here, but a `CSI ? … m` must not masquerade as a style change).
if (match[3] === "m" && match[2] === "" && !/^[<=>?]/.test(params)) {
// An empty parameter list means SGR 0 (`ESC[m` is a reset).
const params = match[1] === "" ? [0] : match[1].split(";").map((n) => Number.parseInt(n, 10) || 0);
applySgr(state, params);
const parsed = params === "" ? [0] : params.split(";").map((n) => Number.parseInt(n, 10) || 0);
applySgr(state, parsed);
}
last = match.index + match[0].length;
match = CSI.exec(line);
Expand Down
Loading
Loading