Fix two serialize round-trip bugs garbling hidden-terminal snapshot restore

BUG B (addon patch): @xterm/addon-serialize's SGR diff emitted bold/dim set
params before the shared intensity reset 22, so "1;22" wiped a freshly set
bold and a bare "22" dropped a still-set bold/dim. Patched via pnpm
patchedDependencies (config/patches) to diff bold+dim as one intensity
group with the clearing 22 emitted first. Other flag pairs (4/24, 3/23,
7/27, ...) have dedicated resets and were verified unaffected.

BUG C (Orca-side hardening): the addon restores the cursor with relative
moves computed from where it assumes replay leaves the cursor; a final row
filled exactly to the right margin leaves replay wrap-pending and the
restore lands one column short. New shared
serializeWithAbsoluteCursor appends an absolute CUP from the source
terminal's authoritative cursor at every restore/replay serialize site
(daemon/runtime HeadlessEmulator.getSnapshot, renderer mobile snapshot
serializer, shutdown layout capture). It skips empty snapshots and
wrap-pending sources so it never changes already-correct behavior.

Round-trip repros + non-regression coverage in
src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts (verified
failing with the fixes stashed). buildRehydrateSequences extracted to its
own module to keep headless-emulator.ts under the max-lines budget.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 20:42:00 -04:00
co-authored by Orca
parent 7fc8db81b7
commit 28016b837d
10 changed files with 472 additions and 69 deletions
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -252,7 +252,8 @@
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch",
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch"
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch"
}
},
"reactDoctor": {
+5 -2
View File
@@ -8,6 +8,9 @@ patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
'@xterm/addon-serialize@0.15.0-beta.287':
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
@@ -36,7 +39,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -9288,7 +9291,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
+9 -46
View File
@@ -3,8 +3,10 @@ import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
import {
@@ -289,8 +291,13 @@ export class HeadlessEmulator {
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot {
const modes = this.getModes()
// Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor
// restore lands one column short after a margin-filling final row leaves
// replay wrap-pending; the trailing CUP survives the alt-marker slice.
const snapshotAnsi = this.normalizeSnapshotAnsiForModes(
this.serializer.serialize({ scrollback: opts.scrollbackRows }),
serializeWithAbsoluteCursor(this.serializer, this.terminal, {
scrollback: opts.scrollbackRows
}),
modes
)
return {
@@ -301,7 +308,7 @@ export class HeadlessEmulator {
opts.scrollbackRows,
this.restoredOscLinks
),
rehydrateSequences: this.buildRehydrateSequences(modes),
rehydrateSequences: buildRehydrateSequences(modes),
cwd: this.oscText.cwd,
modes,
cols: this.terminal.cols,
@@ -386,48 +393,4 @@ export class HeadlessEmulator {
?.flags
return typeof flags === 'number' ? flags : 0
}
private buildRehydrateSequences(modes: TerminalModes): string {
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
// protocol-conformant programs re-push.
const seqs: string[] = []
if (modes.alternateScreen) {
seqs.push('\x1b[?1049h')
}
if (modes.bracketedPaste) {
seqs.push('\x1b[?2004h')
}
if (modes.applicationCursor) {
seqs.push('\x1b[?1h')
}
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
case 'x10':
seqs.push('\x1b[?9h')
break
case 'vt200':
seqs.push('\x1b[?1000h')
break
case 'drag':
seqs.push('\x1b[?1002h')
break
case 'any':
seqs.push('\x1b[?1003h')
break
case 'none':
break
}
// Why: xterm tracks the mouse protocol and SGR encoding as independent
// modes, so snapshots must preserve the encoding even when reporting is off.
if (modes.sgrMousePixelsMode) {
seqs.push('\x1b[?1016h')
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
}
return seqs.join('')
}
}
@@ -0,0 +1,45 @@
import type { TerminalModes } from './types'
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
// protocol-conformant programs re-push.
export function buildRehydrateSequences(modes: TerminalModes): string {
const seqs: string[] = []
if (modes.alternateScreen) {
seqs.push('\x1b[?1049h')
}
if (modes.bracketedPaste) {
seqs.push('\x1b[?2004h')
}
if (modes.applicationCursor) {
seqs.push('\x1b[?1h')
}
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
case 'x10':
seqs.push('\x1b[?9h')
break
case 'vt200':
seqs.push('\x1b[?1000h')
break
case 'drag':
seqs.push('\x1b[?1002h')
break
case 'any':
seqs.push('\x1b[?1003h')
break
case 'none':
break
}
// Why: xterm tracks the mouse protocol and SGR encoding as independent
// modes, so snapshots must preserve the encoding even when reporting is off.
if (modes.sgrMousePixelsMode) {
seqs.push('\x1b[?1016h')
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
}
return seqs.join('')
}
@@ -0,0 +1,254 @@
// Round-trip guards for two @xterm/addon-serialize defects that garbled
// hidden-terminal snapshot restores (serialize a buffer, replay into a fresh
// identical terminal, compare):
//
// BUG B (fixed by config/patches/@xterm__addon-serialize@*.patch): the SGR
// attribute diff emitted bold/dim set params before the shared intensity
// reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a
// still-set bold/dim.
//
// BUG C (hardened Orca-side via serializeWithAbsoluteCursor): a final content
// row filled exactly to the right margin leaves replay wrap-pending, and the
// addon's RELATIVE cursor restore then lands one column short.
import './xterm-env-polyfill'
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { HeadlessEmulator } from './headless-emulator'
import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor'
type TerminalHarness = { terminal: Terminal; addon: SerializeAddon }
function createTerminal(cols = 10, rows = 5, scrollback = 100): TerminalHarness {
const terminal = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
const addon = new SerializeAddon()
terminal.loadAddon(addon)
return { terminal, addon }
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, () => resolve()))
}
async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise<Terminal> {
const { terminal } = createTerminal(cols, rows, scrollback)
await write(terminal, data)
return terminal
}
function cellAt(
terminal: Terminal,
viewportRow: number,
col: number
): NonNullable<
ReturnType<NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>['getCell']>
> {
const buffer = terminal.buffer.active
const line = buffer.getLine(buffer.baseY + viewportRow)
if (!line) {
throw new Error(`no line at viewport row ${viewportRow}`)
}
const cell = line.getCell(col)
if (!cell) {
throw new Error(`no cell at ${viewportRow},${col}`)
}
return cell
}
function visibleText(terminal: Terminal): string[] {
const buffer = terminal.buffer.active
const lines: string[] = []
for (let row = 0; row < terminal.rows; row += 1) {
lines.push(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')
}
return lines
}
async function roundTripStyles(source: string): Promise<Terminal> {
const { terminal, addon } = createTerminal()
await write(terminal, source)
return replay(addon.serialize())
}
describe('SGR intensity round-trip (BUG B, addon patch)', () => {
it('restores bold set immediately after dim is cleared (minimized repro)', async () => {
const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB')
const b = cellAt(restored, 0, 1)
expect(b.getChars()).toBe('B')
expect(!!b.isBold()).toBe(true)
expect(!!b.isDim()).toBe(false)
})
it('keeps bold when dim is dropped from a bold+dim run', async () => {
const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[1mB')
const b = cellAt(restored, 0, 1)
expect(!!b.isBold()).toBe(true)
expect(!!b.isDim()).toBe(false)
})
it('keeps dim when bold is dropped from a bold+dim run', async () => {
const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[2mB')
const b = cellAt(restored, 0, 1)
expect(!!b.isDim()).toBe(true)
expect(!!b.isBold()).toBe(false)
})
it('non-regression: bold after normal text', async () => {
const restored = await roundTripStyles('A\x1b[1mB')
expect(!!cellAt(restored, 0, 0).isBold()).toBe(false)
expect(!!cellAt(restored, 0, 1).isBold()).toBe(true)
})
it('non-regression: dim after bold', async () => {
const restored = await roundTripStyles('\x1b[1mA\x1b[22m\x1b[2mB')
const a = cellAt(restored, 0, 0)
const b = cellAt(restored, 0, 1)
expect(!!a.isBold()).toBe(true)
expect(!!a.isDim()).toBe(false)
expect(!!b.isDim()).toBe(true)
expect(!!b.isBold()).toBe(false)
})
it('non-regression: bold+dim accumulation survives', async () => {
const restored = await roundTripStyles('\x1b[1mA\x1b[2mB')
const b = cellAt(restored, 0, 1)
expect(!!b.isBold()).toBe(true)
expect(!!b.isDim()).toBe(true)
})
it('non-regression: italic after underline is cleared (dedicated resets)', async () => {
const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[3mB')
const b = cellAt(restored, 0, 1)
expect(!!b.isItalic()).toBe(true)
expect(!!b.isUnderline()).toBe(false)
})
it('non-regression: underline after italic is cleared (dedicated resets)', async () => {
const restored = await roundTripStyles('\x1b[3mA\x1b[23m\x1b[4mB')
const b = cellAt(restored, 0, 1)
expect(!!b.isUnderline()).toBe(true)
expect(!!b.isItalic()).toBe(false)
})
it('non-regression: underline dropped alongside bold set', async () => {
const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[1;4mB\x1b[24mC')
const c = cellAt(restored, 0, 2)
expect(!!c.isBold()).toBe(true)
expect(!!c.isUnderline()).toBe(false)
})
})
describe('cursor restore after wrap-pending replay (BUG C, absolute-cursor hardening)', () => {
const REPRO = '0123456789\x1b[3;5H'
it('documents the upstream defect: plain serialize lands one column short', async () => {
// Why this pin: the Orca hardening exists only because of this relative-
// restore defect. If an addon bump makes this fail, the hardening can go.
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, REPRO)
expect(terminal.buffer.active.cursorX).toBe(4)
const restored = await replay(addon.serialize())
expect(restored.buffer.active.cursorX).toBe(3)
})
it('HeadlessEmulator snapshot restores the exact cursor (minimized repro)', async () => {
const emulator = new HeadlessEmulator({ cols: 10, rows: 5 })
expect(emulator.writeSync(REPRO)).toBe(true)
const snapshot = emulator.getSnapshot()
const restored = await replay(snapshot.snapshotAnsi)
expect(restored.buffer.active.cursorX).toBe(4)
expect(restored.buffer.active.cursorY).toBe(2)
expect(visibleText(restored)[0]).toBe('0123456789')
emulator.dispose()
})
it('serializeWithAbsoluteCursor restores the exact cursor', async () => {
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, REPRO)
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
expect(restored.buffer.active.cursorX).toBe(4)
expect(restored.buffer.active.cursorY).toBe(2)
})
it('never changes already-correct restores at various cursor positions', async () => {
const positions = ['\x1b[1;1H', '\x1b[2;4H', '\x1b[5;10H', '\x1b[4;1H']
for (const cup of positions) {
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, `hello\r\nworld${cup}`)
const plainRestore = await replay(addon.serialize())
const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal))
expect(hardenedRestore.buffer.active.cursorX).toBe(terminal.buffer.active.cursorX)
expect(hardenedRestore.buffer.active.cursorY).toBe(terminal.buffer.active.cursorY)
expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX)
expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY)
expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore))
}
})
it('leaves a wrap-pending source untouched so replay stays wrap-pending', async () => {
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, '0123456789')
// cursorX == cols marks pending wrap; a CUP would clamp and clear it.
expect(terminal.buffer.active.cursorX).toBe(10)
const plain = addon.serialize()
expect(serializeWithAbsoluteCursor(addon, terminal)).toBe(plain)
const restored = await replay(plain)
await write(restored, 'Z')
expect(visibleText(restored)[1]).toBe('Z')
})
it('handles a wrap-pending row that is NOT the final content row', async () => {
const { terminal, addon } = createTerminal(10, 5)
// Row 0 fills to the margin and wraps into row 1, then the cursor moves.
await write(terminal, '0123456789ABC\x1b[2;2H')
expect(terminal.buffer.active.cursorY).toBe(1)
expect(terminal.buffer.active.cursorX).toBe(1)
const plainRestore = await replay(addon.serialize())
const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal))
expect(hardenedRestore.buffer.active.cursorX).toBe(1)
expect(hardenedRestore.buffer.active.cursorY).toBe(1)
expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore))
expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX)
expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY)
})
it('restores alt-screen snapshots without disturbing correct positioning', async () => {
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, 'shell$\x1b[?1049h\x1b[2J\x1b[HTUI ROW\x1b[2;3H')
expect(terminal.buffer.active.type).toBe('alternate')
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
expect(restored.buffer.active.type).toBe('alternate')
expect(restored.buffer.active.cursorX).toBe(2)
expect(restored.buffer.active.cursorY).toBe(1)
expect(visibleText(restored)[0]).toBe('TUI ROW')
})
it('fixes the wrap-pending off-by-one inside the alt screen too', async () => {
const { terminal, addon } = createTerminal(10, 5)
await write(terminal, '\x1b[?1049h0123456789\x1b[3;5H')
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
expect(restored.buffer.active.type).toBe('alternate')
expect(restored.buffer.active.cursorX).toBe(4)
expect(restored.buffer.active.cursorY).toBe(2)
})
it('restores scrolled-back buffers with the cursor at its base-relative spot', async () => {
const { terminal, addon } = createTerminal(10, 3, 50)
for (let i = 0; i < 8; i += 1) {
await write(terminal, `line${i}\r\n`)
}
await write(terminal, '\x1b[2;3H')
const source = { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY }
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal), 10, 3, 50)
expect(restored.buffer.active.cursorX).toBe(source.x)
expect(restored.buffer.active.cursorY).toBe(source.y)
expect(visibleText(restored)).toEqual(visibleText(terminal))
expect(restored.buffer.active.length).toBe(terminal.buffer.active.length)
})
it('keeps empty buffers serializing to an empty string', async () => {
const { terminal, addon } = createTerminal(10, 5)
expect(addon.serialize()).toBe('')
expect(serializeWithAbsoluteCursor(addon, terminal)).toBe('')
})
})
@@ -14,6 +14,7 @@ import { getWorktreeMapFromState } from '@/store/selectors'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic'
import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
@@ -3244,10 +3245,15 @@ export function connectPanePty(
// into the seed when the user is mid-TUI; the read-fallback path
// omits it because it wants the user's currently-visible content.
const alt = pane.terminal.buffer.active.type === 'alternate'
// Why serializeWithAbsoluteCursor: SerializeAddon's relative
// cursor restore lands one column short when replay of a
// margin-filling final row leaves the target wrap-pending.
const data =
opts?.altScreenForcesZeroRows && alt
? pane.serializeAddon.serialize({ scrollback: 0 })
: pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })
? serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { scrollback: 0 })
: serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
scrollback: opts?.scrollbackRows
})
return {
data,
cols: pane.terminal.cols,
@@ -6,6 +6,25 @@ import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const
// Why: capture now appends an absolute cursor restore (see
// terminal-serialize-absolute-cursor.ts), so pane mocks must expose the
// cursor fields and content expectations carry the home-position CUP suffix.
const CURSOR_HOME = '\x1b[1;1H'
function mockTerminal(scrollback: number): {
options: { scrollback: number }
cols: number
rows: number
buffer: { active: { cursorX: number; cursorY: number } }
} {
return {
options: { scrollback },
cols: 80,
rows: 24,
buffer: { active: { cursorX: 0, cursorY: 0 } }
}
}
const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn()
}))
@@ -76,7 +95,7 @@ describe('captureTerminalShutdownLayout', () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const order: string[] = []
const terminal = {
options: { scrollback: 1_000 },
...mockTerminal(1_000),
pendingOutput: ''
}
const pane = {
@@ -115,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' },
buffersByLeafId: { [LEAF_ID]: `snapshot:queued-before-quit${CURSOR_HOME}` },
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' },
titlesByLeafId: { [LEAF_ID]: 'build logs' }
})
@@ -127,7 +146,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 50_000 } },
terminal: mockTerminal(50_000),
serializeAddon: {
serialize: vi.fn(() => 'x'.repeat(512 * 1024))
}
@@ -166,7 +185,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 512 } },
terminal: mockTerminal(512),
serializeAddon: {
serialize: vi.fn((options?: { scrollback?: number }) =>
multibyteRow.repeat(options?.scrollback ?? 0)
@@ -192,7 +211,12 @@ describe('captureTerminalShutdownLayout', () => {
expect(getUtf8ByteLength(buffer)).toBeLessThanOrEqual(
TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
)
expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT / 2)
// Each 'e-acute' row is 2048 UTF-8 bytes; the CUP suffix joins the byte
// accounting, so one fewer row fits than the bare limit would allow.
const fittingRows = Math.floor(
(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT - CURSOR_HOME.length) / 2048
)
expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length)
})
it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => {
@@ -201,7 +225,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => '')
}
@@ -239,7 +263,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => 'dead scrollback')
}
@@ -248,7 +272,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 2,
leafId: LEAF_ID_2,
stablePaneId: LEAF_ID_2,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => 'live scrollback')
}
@@ -275,8 +299,8 @@ describe('captureTerminalShutdownLayout', () => {
expect(layout.activeLeafId).toBe(LEAF_ID_2)
expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-live' })
expect(layout.buffersByLeafId).toEqual({
[LEAF_ID]: 'dead scrollback',
[LEAF_ID_2]: 'live scrollback'
[LEAF_ID]: `dead scrollback${CURSOR_HOME}`,
[LEAF_ID_2]: `live scrollback${CURSOR_HOME}`
})
})
@@ -286,7 +310,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => 'first scrollback')
}
@@ -295,7 +319,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 2,
leafId: LEAF_ID_2,
stablePaneId: LEAF_ID_2,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => 'second scrollback')
}
@@ -335,7 +359,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => '')
}
@@ -344,7 +368,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 2,
leafId: LEAF_ID_2,
stablePaneId: LEAF_ID_2,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => '')
}
@@ -373,7 +397,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => '')
}
@@ -382,7 +406,7 @@ describe('captureTerminalShutdownLayout', () => {
id: 2,
leafId: LEAF_ID_2,
stablePaneId: LEAF_ID_2,
terminal: { options: { scrollback: 1_000 } },
terminal: mockTerminal(1_000),
serializeAddon: {
serialize: vi.fn(() => '')
}
@@ -6,6 +6,7 @@ import { serializeTerminalLayout } from './layout-serialization'
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
@@ -66,7 +67,12 @@ export function captureTerminalShutdownLayout({
flushTerminalOutput(pane.terminal)
const leafId = pane.leafId
let scrollback = pane.terminal.options.scrollback ?? 10_000
let serialized = pane.serializeAddon.serialize({ scrollback })
// Why serializeWithAbsoluteCursor: these buffers replay into fresh
// xterms on session restore, and SerializeAddon's relative cursor
// restore lands one column short after a wrap-pending final row.
let serialized = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
scrollback
})
// Why: SSH sleep keeps this string in session JSON; cap by UTF-8
// bytes so non-ASCII scrollback cannot bypass the intended bound.
if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) {
@@ -75,7 +81,9 @@ export function captureTerminalShutdownLayout({
let best = ''
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const attempt = pane.serializeAddon.serialize({ scrollback: mid })
const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
scrollback: mid
})
if (fitsSessionScrollbackByteLimit(attempt)) {
best = attempt
lo = mid + 1
@@ -0,0 +1,44 @@
// 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.
type SerializeCursorTerminal = {
cols: number
rows: number
buffer: { active: { cursorX: number; cursorY: number } }
}
type BufferSerializer<TOpts> = {
serialize: (opts?: TOpts) => string
}
export function serializeWithAbsoluteCursor<TOpts>(
serializer: BufferSerializer<TOpts>,
terminal: SerializeCursorTerminal,
opts?: TOpts
): 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
}
const { cursorX, cursorY } = terminal.buffer.active
// 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.
if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) {
return serialized
}
// cursorY is viewport-relative (0 at the buffer's base row), which is the
// same coordinate space CUP addresses after replay; scrollback length
// differences between source and destination do not shift it.
return `${serialized}\x1b[${cursorY + 1};${cursorX + 1}H`
}