mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
File diff suppressed because one or more lines are too long
+2
-1
@@ -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": {
|
||||
|
||||
Generated
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+40
-16
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user