fix(terminal): keep row alignment in the full-history pane replay

Switching to a session left the caret one row below the composer's input
line, on the box border, and every cursor-relative update the CLI sent
afterwards was measured from the wrong row. Any fresh output repaired it,
because the CLI then repainted the whole frame.

Two things were wrong with the full-history replay, and they compound.

The capture never restored the cursor. The visible-frame path ends with an
absolute cursor move back to the pane's position; the linear path returned
its text and left the caret wherever the last character landed, which for an
agent CLI is the bottom-most row carrying text — the status line.

The rows it addressed did not line up with the pane's rows either. Four
transforms ran over the capture and each can delete a line: the trailing
blank rows were stripped, redraw-bloat stripping ran, the trim that cuts
everything above the Claude banner ran, and leading whitespace was removed.
All four are right for a byte stream of successive frames. A capture is the
rendered pane, one line per screen row, so each deletion shifted the frame
out from under the restored cursor.

The full-history path now appends the pane's own cursor position and keeps
every row, so row N of the reply is row N of the pane. The visible-frame and
tail paths are untouched.

Restoring the cursor is what makes row alignment load-bearing here, and
neither CLAUDE.md nor the architecture invariants said so — which is how
four line-deleting transforms accumulated on the path. Both now record it.

Verified against a live 315x59 pane: the reply carries 59 rows, its row 55
is the composer's input line matching tmux, and it ends with the cursor move
that lands there.
This commit is contained in:
Michael Grundberg
2026-09-09 14:20:34 +02:00
parent 5130ca6633
commit 323730a29d
6 changed files with 95 additions and 18 deletions
+1 -1
View File
@@ -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 is the rendered pane, one line per screen row, and it ENDS with an absolute cursor move back to the pane's own position** — 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. That makes row alignment load-bearing on this path only: 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. All three stay for the byte-stream and `?tail=` paths, where nothing depends on a row's absolute index. ⚠️ 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
+23 -7
View File
@@ -3262,18 +3262,19 @@ 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,
''
);
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
// The visible path drops every trailing blank row, which is harmless there
// because it repaints each row at an absolute position afterwards. The
// full-history path replays linearly, so its trailing blank rows are the
// real bottom of the screen and dropping them would move every row up and
// leave the restored cursor pointing at the wrong line. Take off the last
// line terminator only.
const buffer = fullHistory ? rawCapture.replace(/\n$/, '') : rawCapture.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(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
@@ -3283,6 +3284,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
).trim();
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
// Put the cursor back where the pane has it. A linear replay leaves it
// wherever the last character landed, which is the bottom-most row
// carrying text — the status line, for an agent CLI. The caret then sits
// there until the CLI's next redraw moves it, and every cursor-relative
// update the CLI sends until then is measured from the wrong row.
// Absolute addressing is safe because the replay ends with the pane's
// own last row at the bottom of the viewport.
if (fullHistory) {
const normalized = normalizeScrollbackEol(buffer);
if (Number.isFinite(cursorX) && Number.isFinite(cursorY) && cursorX >= 0 && cursorY >= 0) {
return `${normalized}\x1b[${cursorY + 1};${cursorX + 1}H`;
}
return normalized;
}
if (
Number.isFinite(cursorX) &&
Number.isFinite(cursorY) &&
@@ -3297,6 +3312,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
if (fullHistory) return normalizeScrollbackEol(buffer);
}
// Cursor query failed or geometry was invalid, so we skip the absolute-
// positioned snapshot repaint and fall back to the raw capture. Normalize
+18 -3
View File
@@ -2649,8 +2649,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);
isFullReload || 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 +2696,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 = isFullReload ? 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 +2710,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 (!isFullReload) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
const finishedAt = performance.now();
reply.header(
+27
View File
@@ -851,6 +851,33 @@ 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) falls back to the byte history when the capture is unavailable', async () => {
harness.ctx._session.mode = 'claude';
harness.ctx._session.terminalBuffer = 'byte history survives';
+25 -6
View File
@@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest';
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);
const methodBody = source.slice(methodStart, methodStart + 6500);
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
expect(methodStart).toBeGreaterThan(-1);
@@ -42,13 +42,32 @@ 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);');
// When fullHistory, return the normalized buffer BEFORE the
// formatPaneSnapshot repaint (which is single-screen and would clip a
// multi-screen history).
const normalize = methodBody.indexOf('normalizeScrollbackEol(buffer)');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(earlyReturn).toBeGreaterThan(-1);
expect(normalize).toBeGreaterThan(-1);
expect(snapshot).toBeGreaterThan(-1);
expect(earlyReturn).toBeLessThan(snapshot);
expect(normalize).toBeLessThan(snapshot);
});
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('return `${normalized}');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(restore).toBeGreaterThan(-1);
expect(restore).toBeLessThan(snapshot);
expect(methodBody).toContain('cursorY + 1};${cursorX + 1}H');
});
it('keeps the trailing rows of a full-history capture', () => {
// The visible path drops trailing blank rows because it repaints each row
// absolutely afterwards. Dropping them on the linear path would move the
// frame up and leave the restored cursor pointing at the wrong line.
expect(methodBody).toContain("fullHistory ? rawCapture.replace(/\\n$/, '')");
});
it('captureActivePaneBuffer forwards the capture options', () => {