Files
orca/src/shared/terminal-serialize-absolute-cursor.ts
T
Brennan Benson f2984e2230 fix(terminal): skip a too-wide alt frame on snapshot replay (#13014)
* fix(terminal): skip a too-wide alt frame on snapshot replay

Reopening a parked worktree could paint a stale full-width TUI frame
through a narrower viewport, leaving clipped gutter fragments and
mid-word omissions until the live application repainted.

Replay pins xterm to the snapshot grid so soft-wrapped normal-buffer
history stays exact (#7279), then the post-replay fit returns the pane
to its container grid. Alternate buffers have no scrollback and do not
reflow; an absolutely positioned frame remains at its capture layout,
so narrowing exposes only clipped portions of those fixed-grid rows.

Skip only the visual frame when its capture is wider than the grid the
fit will land on. The alt buffer is still entered and cleared, so the
resize signal lands on a clean screen that the live application can
repaint. Equal-width and wider restores retain the frame, while normal
history always replays at its capture grid before fitting.

The target width comes from proposeDimensions, not terminal.cols: an
unfitted pane can still read xterm's default grid even when its actual
container matches the capture.

* fix(terminal): preserve offline SSH prepaint frame

* fix(terminal): drop a too-wide daemon alt frame on reattach

The renderer-only gate did not run on the user-visible remount path.
Instrumentation showed that daemon-connectResult-snapshot won before
the model-snapshot branches, so the composed snapshot painted in full
and the later narrower fit exposed its stale fixed-grid frame clipped
at the new viewport.

The daemon branch could not omit only the visual frame while main sent
one merged string. Publish the normal-buffer/mode prefix and visual alt
frame as additive optional metadata while retaining the merged snapshot
for mixed-version fallback. New renderers can keep history and restore
state without painting a frame captured for a wider grid.

Replay ordering remains capture-grid, write, then fit so normal-buffer
soft wrapping stays exact. The application owns the foreign-width alt
frame and repaints it after the resize signal rather than Orca trying to
transform an absolutely positioned screen.

* fix(terminal): preserve split daemon snapshot payload

* fix(terminal): preserve live state when dropping alt frame

* fix(terminal): restore DECOM cursor state exactly

* fix(terminal): preserve ordinary snapshot bytes

* test(terminal): cover fixed-grid alt replay resize

* fix(terminal): repaint after dropping mismatched frames

A hidden snapshot can omit an alternate-screen frame before the pane has a measurable target grid. If reveal later lands on the capture grid, a same-size PTY resize emits no SIGWINCH, so pulse the local PTY size whenever that frame was skipped.\n\nKeep performSafeFit's measurable-pane contract intact, publish daemon snapshot prefix and frame as explicit optional strings, and fall back to the merged payload when either field is absent. Cold owner-gone restores now omit a mismatched frame while retaining history and fresh-shell reset treatment; offline SSH preconnect remains unchanged.\n\nPin the vendored SerializeAddon out-of-range-row behavior used to capture live SGR state.
2026-08-10 18:24:04 -07:00

134 lines
6.1 KiB
TypeScript

// Why this module exists: @xterm/addon-serialize restores the cursor with
// RELATIVE moves (CUD/CUB) computed from where it assumes replay leaves the
// cursor. When the final content row is filled exactly to the right margin,
// replay leaves the fresh terminal wrap-pending (internal x == cols), so the
// relative math lands one column short of the real cursor. Every Orca buffer
// snapshot that will be replayed into another terminal must therefore end
// with an absolute CUP derived from the SOURCE terminal's authoritative
// cursor position. Snapshot producers that also need the VT100 DECSC
// saved-cursor register carried across the restore compose it here too.
type SerializeCursorTerminal = {
cols: number
rows: number
buffer: { active: { cursorX: number; cursorY: number } }
}
type BufferSerializer<TOpts> = {
serialize: (opts?: TOpts) => string
}
/** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */
export type SavedCursorRegister = { x: number; y: number; originMode: boolean }
// xterm keeps the DECSC register on each Buffer (savedY is absolute:
// ybase-included). It is not exposed through the public API, so snapshot
// producers read the core buffer directly — `_core.buffer` is the ACTIVE
// buffer, so an alt-screen TUI yields the alternate screen's own register,
// matching the one a post-restore DECRC would consult.
type TerminalWithSavedCursorCore = SerializeCursorTerminal & {
modes?: { originMode?: boolean }
_core?: {
buffer?: {
savedX?: number
savedY?: number
savedOriginMode?: boolean
ybase?: number
scrollTop?: number
scrollBottom?: number
}
}
}
/** Reads the source terminal's active-buffer DECSC register, or null when it
* is unavailable or indistinguishable from the never-saved default. */
export function readSavedCursorRegister(
terminal: SerializeCursorTerminal
): SavedCursorRegister | null {
const core = (terminal as TerminalWithSavedCursorCore)._core?.buffer
if (
typeof core?.savedX !== 'number' ||
typeof core.savedY !== 'number' ||
typeof core.ybase !== 'number'
) {
return null
}
// savedY is absolute; DECRC restores it relative to the ybase current at
// restore time, clamping at the top — mirror that clamp here. savedX can be
// cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp.
const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1)
const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1)
const originMode = core.savedOriginMode === true
if (x === 0 && y === 0 && !originMode) {
// Home is xterm's never-saved default: a fresh restore terminal already
// sends DECRC to home, and skipping the injection avoids overwriting the
// fresh terminal's default saved SGR/charset when nothing was ever saved.
return null
}
return { x, y, originMode }
}
export function serializeWithAbsoluteCursor<TOpts>(
serializer: BufferSerializer<TOpts>,
terminal: SerializeCursorTerminal,
opts?: TOpts,
savedCursor?: SavedCursorRegister | null
): string {
const serialized = serializer.serialize(opts)
// Why skip empty snapshots: several callers treat '' as "nothing to
// restore" (e.g. shutdown layout capture drops empty buffers); a bare CUP
// would turn every idle pane into a persisted snapshot.
if (serialized.length === 0) {
return serialized
}
return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}`
}
/** Cursor state appended after serialized modes; safe to replay without the frame body. */
export function buildAbsoluteCursorRestoreSequence(
terminal: SerializeCursorTerminal,
savedCursor?: SavedCursorRegister | null,
options: { restoreModesWithoutCursor?: boolean } = {}
): string {
const { cursorX, cursorY } = terminal.buffer.active
const terminalWithCore = terminal as TerminalWithSavedCursorCore
const buffer = terminalWithCore._core?.buffer
const scrollTop = buffer?.scrollTop ?? 0
const scrollBottom = buffer?.scrollBottom ?? terminal.rows - 1
const originMode = terminalWithCore.modes?.originMode === true
const cupCursorY = cursorY - (originMode ? scrollTop : 0)
// Why skip wrap-pending sources (cursorX == cols): plain replay already
// reproduces that state exactly, while CUP would clamp to the last column
// and clear the pending-wrap flag, changing how the next byte renders.
// The remaining bounds checks are defensive: never emit a clamping CUP.
const canRestoreCurrentCursor =
cursorX >= 0 && cursorX < terminal.cols && cupCursorY >= 0 && cupCursorY < terminal.rows
if (!canRestoreCurrentCursor && options.restoreModesWithoutCursor !== true) {
return ''
}
// Why the DECSC injection: the serialized screen cannot carry the VT100
// saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC
// restored to home and clobbered live cells (Bug D in
// notes/garble-fuzz-divergences.md). Re-establish the register by saving at
// the source's saved position, then CUP back to the real cursor. Saved SGR/
// charset are not carried — the synthetic ESC 7 saves the serializer's
// final pen, a deliberate position-only fidelity trade.
// Install the saved register against a full region so its absolute row is
// representable even when it predates the current DECSTBM/DECOM state.
const savedRestore = savedCursor
? `\x1b[r\x1b[?6${savedCursor.originMode ? 'h' : 'l'}\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7`
: ''
const mustRestoreModes = savedCursor != null || options.restoreModesWithoutCursor === true
const scrollRegionRestore =
mustRestoreModes && (scrollTop !== 0 || scrollBottom !== terminal.rows - 1)
? `\x1b[${scrollTop + 1};${scrollBottom + 1}r`
: ''
const originModeRestore = mustRestoreModes ? `\x1b[?6${originMode ? 'h' : 'l'}` : ''
// CUP rows become margin-relative under DECOM; ordinary snapshots keep the
// zero offset because scrollback length does not shift viewport coordinates.
const currentCursorRestore = canRestoreCurrentCursor
? `\x1b[${cupCursorY + 1};${cursorX + 1}H`
: ''
return `${savedRestore}${scrollRegionRestore}${originModeRestore}${currentCursorRestore}`
}