diff --git a/CLAUDE.md b/CLAUDE.md index 0bd07288..30b22020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,7 +247,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ba4239c0..167cda8d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -114,7 +114,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[;H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. ### Terminal scrollback: strip flavors and wheel/touch forwarding diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f4e3dd24..21065efe 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -137,7 +137,12 @@ export interface RespawnPaneOptions { /** Options for pane buffer capture (COD-47 full-history mode). */ export interface PaneCaptureOptions { - /** Capture the entire tmux scrollback instead of just the visible frame. */ + /** + * Capture the entire scrollback instead of just the visible frame, as linear + * text ending with a cursor move back to the pane's caret position. An + * implementation returns '' when the pane holds nothing visible, which the + * caller reads as "nothing to replay" and keeps its existing history. + */ fullHistory?: boolean; /** Bound the full-history capture to this many scrollback lines (`-S -`). */ historyLimitLines?: number; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9fdd2bda..4631725b 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -528,6 +528,73 @@ export function normalizeScrollbackEol(buffer: string): string { return buffer.replace(/\r?\n/g, '\r\n'); } +/** Pane geometry and caret position, as `display-message` reports them. */ +interface PaneCursorGeometry { + cols: number; + rows: number; + cursorX: number; + cursorY: number; +} + +/** + * Read the pane's cursor and size, or null when tmux cannot say. + * + * Every field is validated together: a caller that gets a value back can place + * a caret with it, and one that gets null must not try. + */ +export function queryPaneCursor(run: () => string): PaneCursorGeometry | null { + let raw: string; + try { + raw = run().trim(); + } catch (cursorErr) { + console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr); + return null; + } + const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10)); + if ( + !Number.isFinite(cursorX) || + !Number.isFinite(cursorY) || + !Number.isFinite(cols) || + !Number.isFinite(rows) || + cursorX < 0 || + cursorY < 0 || + cols <= 0 || + rows <= 0 + ) { + return null; + } + return { cols, rows, cursorX, cursorY }; +} + +/** SGR attributes, which is all `capture-pane -e` emits. */ +// eslint-disable-next-line no-control-regex +const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g; + +/** Whether a capture holds anything a reader would see, styles discounted. */ +export function hasVisibleContent(capture: string): boolean { + return /\S/.test(capture.replace(CAPTURE_STYLE_SEQUENCE, '')); +} + +/** + * Put the caret back where the pane has it, counting UP from the bottom of what + * was just replayed. + * + * Relative rather than absolute (`CUP`) on purpose. `\x1b[;H` numbers + * rows from the top of the browser's screen, so it only lands correctly while + * the browser's row count equals the pane's — and it need not, because + * `resizeWindow` fires its tmux resize without waiting, so a capture can be + * taken before a requested resize has been applied. Counting up from the last + * replayed row is anchored to the content instead, which is the thing both ends + * genuinely share. + */ +export function formatCursorRestore(geometry: PaneCursorGeometry): string { + const up = Math.max(0, geometry.rows - 1 - geometry.cursorY); + const right = Math.max(0, geometry.cursorX); + // `\r` first so the column is known: the replay leaves the caret wherever the + // last row's text ended. + return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -3199,11 +3266,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * the browser xterm reproduces the live frame. Used for fast tab switches. * - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -` grabs * the tmux scrollback (COD-47, bounded to the configured history limit), - * returned as linear scrollback text with SGR codes preserved (NOT - * repositioned — a multi-screen history can't be painted into a single - * visible frame, so the snapshot repaint is skipped). `-J` re-joins lines - * hard-wrapped at the pane width so they reflow in the browser xterm. - * Used for full page reloads so the user gets back their scroll history. + * returned as linear scrollback text with SGR codes preserved. Rows are not + * repainted at absolute positions — a multi-screen history can't be painted + * into a single visible frame — but the capture DOES end with a cursor move + * putting the caret back where the pane has it, counted up from the last + * replayed row. `-J` re-joins lines hard-wrapped at the pane width so they + * reflow in the browser xterm. Used for full page reloads so the user gets + * back their scroll history. Returns '' for a pane holding nothing visible, + * so the caller keeps whatever history it already had. * Caveat: lines tmux has already evicted past its history-limit are gone. */ /** @@ -3262,42 +3332,41 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execOpts.maxBuffer = (opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; } - const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace( - /\n+$/g, - '' - ); - // Full-history spans many screens — return it as raw linear scrollback - // rather than repainting rows at single-screen absolute positions. tmux - // joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh - // xterm (convertEol:false) starts each replayed line at column 0 instead - // of staircasing diagonally (COD-138). - if (fullHistory) { - return normalizeScrollbackEol(buffer); - } - try { - const cursor = execSync( + const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts); + // Query the cursor BEFORE deciding anything else. On the full-history path + // it settles both how the capture is trimmed and whether a cursor move is + // appended, and those two have to agree: trailing blank rows are only safe + // to keep when a move follows to put the caret back above them. + const geometry = queryPaneCursor(() => + execSync( `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`, - { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - } - ).trim(); - const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10)); - if ( - Number.isFinite(cursorX) && - Number.isFinite(cursorY) && - Number.isFinite(cols) && - Number.isFinite(rows) && - cursorX >= 0 && - cursorY >= 0 && - cols > 0 && - rows > 0 - ) { - return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY }); - } - } catch (cursorErr) { - console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr); + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ) + ); + + if (fullHistory) { + // Without geometry there is no cursor move, so fall back to the old trim. + // Keeping the blank rows here would park the caret at the bottom of the + // pane with nothing to correct it — worse than not trying at all. + if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, '')); + // Take the line terminator off and nothing else. The trailing blank rows + // that remain are the real bottom of the screen, and the cursor move + // below counts up from it. tmux joins rows with a bare `\n`; normalize to + // `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at + // column 0 instead of staircasing diagonally (COD-138). + const trimmed = rawCapture.replace(/\n$/, ''); + // An all-blank pane has to keep reading as "nothing to replay". The caller + // treats an empty string as "capture unavailable" and keeps the byte + // history; blank rows plus a cursor move are not empty, so without this a + // blank pane REPLACES that history with a blank screen — the downgrade + // `_replayWouldShrinkBuffer` exists to refuse, arriving from the server + // side where that guard cannot see it. + if (!hasVisibleContent(trimmed)) return ''; + return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`; } + + const buffer = rawCapture.replace(/\n+$/g, ''); + if (geometry) return formatPaneSnapshot(buffer.split('\n'), geometry); // Cursor query failed or geometry was invalid, so we skip the absolute- // positioned snapshot repaint and fall back to the raw capture. Normalize // its bare `\n` line endings to `\r\n` so the replay doesn't staircase diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9ea80060..01f18f7e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2603,6 +2603,12 @@ export function registerSessionRoutes( ? 'mux-full-history' : 'mux-visible' : 'history'; + // What the three row-preserving skips below must key on. `isFullReload` is + // only what the CLIENT ASKED FOR: when the capture comes back null — ENOBUFS, + // a timeout, a vanished pane, or a session with no mux at all — rawBuffer + // falls back to the byte history, which is a stream of successive frames with + // no row alignment to protect and every reason to be stripped. + const isFullCapture = isFullReload && hasLiveMuxBuffer; let rawBuffer: string; if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) { // Full-history capture is the RENDERED form of everything already in the @@ -2649,8 +2655,16 @@ export function registerSessionRoutes( // During long thinking phases, Ink rewrites the same rows thousands of times // (500KB+). Without stripping, tail mode returns only spinner frames and // the terminal appears empty when switching tabs. + // A full reload's buffer IS the rendered pane, one line per screen row, and + // it ends with an absolute cursor move back to the pane's own position. + // Every transform below that can DELETE A LINE would shift the rows out from + // under that position, leaving the caret a row off — on the composer's + // border rather than its input line. Redraw-bloat stripping exists for a + // byte stream of successive frames; a capture holds no successive frames. let strippedBuffer = - getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer); + isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false + ? rawBuffer + : stripInkRedrawBloat(rawBuffer); // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte // streams. xterm.js obeys them by switching to its scrollback-less alt @@ -2688,7 +2702,10 @@ export function registerSessionRoutes( cleanBuffer = strippedBuffer; // Find where Claude banner starts (has color codes before "Claude") - const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); + // Skipped for a full reload: the banner sits at whatever row the pane has + // it, and cutting to it would drop the blank rows above and move every + // row up by that many. + const claudeMatch = isFullCapture ? null : cleanBuffer.match(CLAUDE_BANNER_PATTERN); if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { let lineStart = claudeMatch.index; while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { @@ -2699,7 +2716,11 @@ export function registerSessionRoutes( } // Remove Ctrl+L and leading whitespace (cheap on tailed subset) - cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); + // Leading whitespace goes too, except on a full reload where a leading + // blank line is the pane's own first row and dropping it shifts every row + // up by one. + cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, ''); + if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, ''); const finishedAt = performance.now(); reply.header( diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 839786ad..763a4f8c 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -851,6 +851,85 @@ describe('session-routes', () => { ); }); + it('full reload (?full=1) keeps every leading row so the restored cursor lands on the right line', async () => { + // The capture ends with an absolute cursor move, so its rows and the pane's + // rows must line up one for one. Three transforms used to run over it and + // each could delete a leading line: redraw-bloat stripping, the trim that + // cuts everything above the Claude banner, and a leading-whitespace strip. + // Any one of them shifted the frame up and left the caret a row off. + harness.ctx._session.mode = 'claude'; + harness.ctx._session.terminalBuffer = ''; + // A blank first row, then the banner — the shape a real pane has. + const rendered = ['', '\x1b[1mClaude Code v2.1.266', 'conversation', '\u276f ', '\x1b[4;3H'].join('\r\n'); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? rendered : 'visible frame') + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('mux-full-history'); + // The blank first row survives, so row N of the reply is row N of the pane. + expect(body.data.terminalBuffer.startsWith('\r\n')).toBe(true); + expect(body.data.terminalBuffer.split('\r\n')).toHaveLength(rendered.split('\r\n').length); + }); + + it('full reload (?full=1) still strips the byte history when no capture came back', async () => { + // The row-preserving skips exist for a rendered pane. When the capture is + // unavailable the reply IS the byte stream — successive Ink frames, no row + // alignment to protect — so keying the skips on the query parameter rather + // than on the capture returned it unstripped, which is the whole reason + // stripInkRedrawBloat exists. A session with no mux takes this path on + // every first selection, not just during an outage. + harness.ctx._session.mode = 'claude'; + // A VPA cluster the stripper will collapse: >= 10 sequences, spanning the + // 32KB minimum, with real content after it. + const frame = '\x1b[12d' + 'spinner frame '.repeat(240); + harness.ctx._session.terminalBuffer = frame.repeat(20) + 'REAL CONTENT AFTER THE BLOAT'; + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('history'); + expect(body.data.terminalBuffer).toContain('REAL CONTENT AFTER THE BLOAT'); + // Stripped, not passed through whole. + expect(body.data.terminalBuffer.length).toBeLessThan(harness.ctx._session.terminalBuffer.length); + }); + + it('full reload (?full=1) keeps the byte history when the capture is empty', async () => { + // Pins the contract the capture side relies on: an empty capture means + // "nothing to replay" and the byte history survives. capturePaneBuffer + // returns '' for an all-blank pane precisely to reach this branch, since + // retaining trailing blank rows and appending a cursor move would + // otherwise make a blank screen non-empty and replace the history with it. + // (The blank-pane decision itself is unit-tested on hasVisibleContent — + // capturePaneBuffer short-circuits under IS_TEST_MODE and cannot run here.) + harness.ctx._session.mode = 'claude'; + harness.ctx._session.terminalBuffer = 'a real conversation worth keeping'; + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? '' : 'visible frame') + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('history'); + expect(body.data.terminalBuffer).toContain('a real conversation worth keeping'); + }); + it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => { harness.ctx._session.mode = 'claude'; harness.ctx._session.terminalBuffer = 'byte history survives'; diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 641f7c39..2a829ccd 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -11,11 +11,15 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); - const methodBody = source.slice(methodStart, methodStart + 4000); + // Bounded at the next method so `methodBody` really is one method: the + // ordering assertions below would otherwise be satisfiable by a neighbour. + const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart); + const methodBody = source.slice(methodStart, methodEnd); it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => { expect(methodStart).toBeGreaterThan(-1); @@ -42,13 +46,37 @@ describe('tmux full-history pane capture (COD-47)', () => { }); it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => { - // When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot - // repaint (which is single-screen and would clip a multi-screen history). - const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);'); + // The fullHistory branch returns before the formatPaneSnapshot repaint, + // which is single-screen and would clip a multi-screen history. + const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry'); const snapshot = methodBody.indexOf('formatPaneSnapshot('); - expect(earlyReturn).toBeGreaterThan(-1); + expect(branch).toBeGreaterThan(-1); expect(snapshot).toBeGreaterThan(-1); - expect(earlyReturn).toBeLessThan(snapshot); + expect(branch).toBeLessThan(snapshot); + // …and what it returns is normalized linear scrollback, not a repaint. + expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol('); + }); + + it('appends the pane cursor to the full-history capture', () => { + // A linear replay leaves the caret wherever the last character landed — the + // status line, for an agent CLI — and every cursor-relative update the CLI + // sends afterwards is then measured from the wrong row. + const restore = methodBody.indexOf('formatCursorRestore(geometry)'); + const snapshot = methodBody.indexOf('formatPaneSnapshot('); + expect(restore).toBeGreaterThan(-1); + expect(restore).toBeLessThan(snapshot); + }); + + it('keeps the trailing rows only when a cursor move will follow', () => { + // Trailing blank rows are the bottom of the screen and the cursor move counts + // up from them, so the two decisions travel together: no geometry, no move, + // and the old trim applies instead. + expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')"); + expect(methodBody).toContain("if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\\n+$/g, ''))"); + }); + + it('defers to the byte history when the pane holds nothing visible', () => { + expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''"); }); it('captureActivePaneBuffer forwards the capture options', () => { @@ -59,3 +87,37 @@ describe('tmux full-history pane capture (COD-47)', () => { expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)'); }); }); + +describe('full-history cursor restore', () => { + it('counts up from the last replayed row rather than down from the top', () => { + // Relative, not `CUP`: absolute row addressing is only correct while the + // browser's row count equals the pane's, and resizeWindow does not wait for + // tmux, so a capture can be taken before a requested resize has applied. + expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C'); + }); + + it('emits no row move when the caret is already on the last row', () => { + expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C'); + }); + + it('emits no column move for column zero', () => { + expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r'); + }); +}); + +describe('hasVisibleContent', () => { + it('is false for a pane of blank rows', () => { + expect(hasVisibleContent('\n'.repeat(23))).toBe(false); + }); + + it('is false for blank rows carrying only SGR attributes', () => { + // `capture-pane -e` styles every row, so an all-blank pane is not an empty + // string. Treating it as content would replace the byte history with a + // blank screen. + expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false); + }); + + it('is true as soon as one row carries a character', () => { + expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true); + }); +});