mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #395 from irisitymichaelgrundberg/fix/full-history-replay-row-alignment
fix(terminal): keep row alignment in the full-history pane replay
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
|
||||
+108
-39
@@ -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[<row>;<col>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 -<N>` 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user