mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(terminal): gate the row-preserving skips on a capture, not the query flag
Review of the previous commit found the guard inverted: the three skips keyed on `?full=1`, which 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 — the reply falls back to the byte history, which IS a stream of successive frames and still needs stripping. Gating on the request returned it whole: measured at 82KB against 4KB for the same buffer without `full=1`. A direct-PTY session takes that path on every first selection, not only during an outage. The skips now key on `isFullCapture`, meaning a capture arrived. Three further defects the same review surfaced, all on this path: Keeping the trailing rows is only sound when a cursor move follows to count back up from them. On the two branches where the cursor query fails there is no move, so the caret was left at the bottom of the pane — worse than before. The cursor is now read first and settles both decisions together. The move is relative rather than absolute. `CUP` numbers rows from the top of the browser's screen, so it is only right 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 applies. Measured against real tmux with a browser four rows shorter than the pane: the absolute move lands on a blank row, the relative one lands on the caret's row. An all-blank pane no longer reads as content. Retaining trailing rows and appending a move made it non-empty, and the caller treats non-empty as "replay this", so a blank screen would have replaced real history — the downgrade `_replayWouldShrinkBuffer` refuses, arriving from the server side where that guard cannot see it. The documentation claimed one line per screen row. `-J` joins a hard-wrapped row into its logical line, so that is false whenever any row wrapped: measured at 10 lines for a 12-row pane. Both entries now say what actually holds, and the stale "NOT repositioned" contract in the mux interface is updated too. Tests: the byte-history fallback is stripped, an empty capture leaves history intact, and the extracted helpers are unit-tested directly rather than through source-text matching. The slice window in the capture test is bounded at the next method, having overrun into its neighbours.
This commit is contained in:
@@ -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;
|
||||
|
||||
+107
-54
@@ -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.
|
||||
*/
|
||||
/**
|
||||
@@ -3263,57 +3333,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
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).
|
||||
try {
|
||||
const cursor = execSync(
|
||||
// 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));
|
||||
// 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) &&
|
||||
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);
|
||||
if (fullHistory) return normalizeScrollbackEol(buffer);
|
||||
{ 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
|
||||
@@ -2656,7 +2662,7 @@ export function registerSessionRoutes(
|
||||
// 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 =
|
||||
isFullReload || getCli(session.mode)?.capabilities.stripInkBloat === false
|
||||
isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false
|
||||
? rawBuffer
|
||||
: stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
@@ -2699,7 +2705,7 @@ export function registerSessionRoutes(
|
||||
// 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);
|
||||
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') {
|
||||
@@ -2714,7 +2720,7 @@ export function registerSessionRoutes(
|
||||
// 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, '');
|
||||
if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
const finishedAt = performance.now();
|
||||
reply.header(
|
||||
|
||||
Reference in New Issue
Block a user