diff --git a/src/main/sessionManager.screenGate.test.ts b/src/main/sessionManager.screenGate.test.ts index c08b86359..ed1857eb2 100644 --- a/src/main/sessionManager.screenGate.test.ts +++ b/src/main/sessionManager.screenGate.test.ts @@ -134,4 +134,29 @@ describe('SessionManager screen-frame gate', () => { expect(write).toHaveBeenCalledExactlyOnceWith('\x1b') await manager.kill(sessionId) }) + + it('a remounted raw terminal gets the modes its evicted startup bytes set (#843, real OpenCode recording)', async () => { + const { readFileSync } = await import('node:fs') + const { resolve } = await import('node:path') + const { Terminal } = await import('@xterm/headless') + const recording = JSON.parse(readFileSync(resolve(__dirname, '../../testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json'), 'utf8')) as Array<{ d: string }> + const preamble = recording.findIndex(chunk => chunk.d.includes('\x1b[?1049h')) + const frames = recording.slice(preamble + 1).map(chunk => chunk.d).filter(chunk => chunk.includes('\x1b[?2026h')) + const { SessionManager } = await import('./sessionManager') + const session = new FakeAgentSession() + createSession.mockImplementation(() => session) + const manager = new SessionManager() + const { sessionId } = await manager.spawn({ kind: 'claude', cwd: '/tmp/project' }) + for (const chunk of recording.slice(0, preamble + 1)) session.emit('pty-data', chunk.d) + // Past the real 512 KiB agent cap, as minutes of 60 fps repaint are. + let written = 0 + while (written < 600 * 1024) for (const frame of frames) { session.emit('pty-data', frame); written += frame.length } + const replay = manager.attachAgentPty(sessionId)! + const terminal = new Terminal({ cols: 120, rows: 36, allowProposedApi: true }) + await new Promise(done => terminal.write(replay, done)) + expect(terminal.buffer.active.type).toBe('alternate') + expect(terminal.modes.mouseTrackingMode).toBe('any') + manager.detachAgentPty(sessionId) + await manager.kill(sessionId) + }) }) diff --git a/src/main/sessionManager.terminalReplay.test.ts b/src/main/sessionManager.terminalReplay.test.ts new file mode 100644 index 000000000..b37cd9972 --- /dev/null +++ b/src/main/sessionManager.terminalReplay.test.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' + +// #843 / #1041 review: the SHELL attach path (attachTerminal) must replay the +// modes its evicted bytes set, like the agent path. Reverting it to read() +// passed every test until this one. A full-screen program run from a shell +// (vim, htop, an OpenCode TUI started by hand) sets the alternate screen and +// mouse tracking once; 256 KiB of repaint evicts that. +const terminals = vi.hoisted(() => ({ latest: null as null | EventEmitter })) +vi.mock('@shared/runtime/terminalSession.js', () => ({ + TerminalSession: class FakeTerminalSession extends EventEmitter { + constructor() { super(); terminals.latest = this } + async start(): Promise { this.emit('started') } + async stop(): Promise {} + write(): void {} + resize(): void {} + }, +})) +vi.mock('@main/workspaceDirectory.js', () => ({ + MissingWorkspaceDirectoryError: class extends Error {}, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) +vi.mock('@main/setup/toolchain.js', () => ({ getToolPath: () => '/usr/bin/true' })) +vi.mock('@main/performance/PerformanceService.js', () => ({ + performanceService: { mark: vi.fn(), record: vi.fn(), error: vi.fn(), metric: vi.fn(), span: () => ({ end: vi.fn(), fail: vi.fn() }) }, +})) +vi.mock('@main/storage/feedDebugLog.js', () => ({ forgetFeedDebugSession: vi.fn() })) + +it('a remounted shell terminal gets the alternate screen and mouse mode its evicted bytes set', async () => { + const { Terminal } = await import('@xterm/headless') + const { SessionManager } = await import('./sessionManager') + // No tmux: a direct PTY terminal, the case every machine without tmux runs. + const manager = new SessionManager({ isAvailable: () => false, getBinary: () => null } as never) + const { sessionId } = await manager.spawn({ kind: 'terminal', cwd: '/tmp/project' }) + const shell = terminals.latest! + shell.emit('data', 'user@host project % vim notes.md\r\n') + shell.emit('data', '\x1b[?1049h\x1b[?1000h\x1b[?1006h') + // Past the 256 KiB shell cap with full-screen repaints. + const frame = '\x1b[H' + 'editing notes '.repeat(200) + '\r\n' + for (let written = 0; written < 300 * 1024; written += frame.length) shell.emit('data', frame) + + const replay = manager.attachTerminal(sessionId) + const terminal = new Terminal({ cols: 120, rows: 36, allowProposedApi: true }) + await new Promise(done => terminal.write(replay, done)) + expect(terminal.buffer.active.type).toBe('alternate') + expect(terminal.modes.mouseTrackingMode).toBe('vt200') + await manager.kill(sessionId) +}) diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 528faf0fc..afebaccff 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -46,7 +46,7 @@ import { getToolPath, refreshToolchainFromState } from '@main/setup/toolchain.js import { resolveToolPath } from '@main/setup/binaryResolver.js' import { updateToolPaths } from '@main/setup/setupState.js' import { forgetFeedDebugSession } from '@main/storage/feedDebugLog.js' -import { CappedTextBuffer } from '@main/sessions/cappedTextBuffer.js' +import { TerminalReplayBuffer } from '@main/sessions/terminalReplayBuffer.js' import { ScreenFrameGate } from '@main/sessions/screenFrameGate.js' import type { ConditionCustomAction, @@ -641,7 +641,7 @@ export class SessionManager extends EventEmitter { // WHY CappedTextBuffer and not a string: see src/main/sessions/ // cappedTextBuffer.ts — the string version copied the whole cap on every // chunk and retained sliced-string parents (#726). - private readonly terminalBuffers = new Map() + private readonly terminalBuffers = new Map() private readonly terminalAttached = new Set() // Shell activity producer (#865). Emits on a channel of its own, NOT @@ -676,7 +676,7 @@ export class SessionManager extends EventEmitter { // terminal, and agent PTYs can be noisy. Buffer in main, broadcast // only after an attach, and let the renderer replay the buffer before // draining live bytes. - private readonly agentPtyBuffers = new Map() + private readonly agentPtyBuffers = new Map() private readonly agentPtyAttachCounts = new Map() private readonly agentPtyRestoreSizes = new Map() @@ -2713,7 +2713,7 @@ export class SessionManager extends EventEmitter { }) this.sessionSizes.set(sessionId, initialSize) - this.agentPtyBuffers.set(sessionId, new CappedTextBuffer(AGENT_PTY_BUFFER_CAP)) + this.agentPtyBuffers.set(sessionId, new TerminalReplayBuffer(AGENT_PTY_BUFFER_CAP)) session.on('started', ({ projectDir }) => { if (!ownsEntry()) return this.markActivity(sessionId) @@ -2752,7 +2752,7 @@ export class SessionManager extends EventEmitter { this.markActivity(sessionId) let replay = this.agentPtyBuffers.get(sessionId) if (!replay) { - replay = new CappedTextBuffer(AGENT_PTY_BUFFER_CAP) + replay = new TerminalReplayBuffer(AGENT_PTY_BUFFER_CAP) this.agentPtyBuffers.set(sessionId, replay) } replay.append(data) @@ -3102,7 +3102,7 @@ export class SessionManager extends EventEmitter { // and is replayed to the renderer on attach — see the block // comment on terminalBuffers above for the full reasoning. this.sessionSizes.set(sessionId, initialSize) - this.terminalBuffers.set(sessionId, new CappedTextBuffer(TERMINAL_BUFFER_CAP)) + this.terminalBuffers.set(sessionId, new TerminalReplayBuffer(TERMINAL_BUFFER_CAP)) // Terminal sessions only emit started / data / exit. The 'data' // event carries raw PTY bytes for xterm.js on the renderer side; @@ -3130,7 +3130,7 @@ export class SessionManager extends EventEmitter { // standard terminal scrollback behavior. let replay = this.terminalBuffers.get(sessionId) if (!replay) { - replay = new CappedTextBuffer(TERMINAL_BUFFER_CAP) + replay = new TerminalReplayBuffer(TERMINAL_BUFFER_CAP) this.terminalBuffers.set(sessionId, replay) } replay.append(data) @@ -3305,7 +3305,8 @@ export class SessionManager extends EventEmitter { ) return '' } - const buffer = this.terminalBuffers.get(sessionId)?.read() ?? '' + // replay(), not read(): the modes the evicted bytes set come first (#843). + const buffer = this.terminalBuffers.get(sessionId)?.replay() ?? '' // Flip the attach flag in the SAME synchronous block as reading // the buffer. JavaScript is single-threaded and event emission // can only happen on a later tick, so nothing can sneak in. @@ -3353,7 +3354,8 @@ export class SessionManager extends EventEmitter { ) return null } - const buffer = this.agentPtyBuffers.get(sessionId)?.read() ?? '' + // replay(), not read(): the modes the evicted bytes set come first (#843). + const buffer = this.agentPtyBuffers.get(sessionId)?.replay() ?? '' const attachCount = this.agentPtyAttachCounts.get(sessionId) ?? 0 if (attachCount === 0) { const currentSize = this.sessionSizes.get(sessionId) diff --git a/src/main/sessions/cappedTextBuffer.ts b/src/main/sessions/cappedTextBuffer.ts index 6e5b05983..e0ed782ba 100644 --- a/src/main/sessions/cappedTextBuffer.ts +++ b/src/main/sessions/cappedTextBuffer.ts @@ -43,7 +43,14 @@ export class CappedTextBuffer { private live = 0 readonly pieceSize: number - constructor(readonly cap: number, pieceSize?: number) { + constructor( + readonly cap: number, + pieceSize?: number, + /** Called with every span of text the cap discards, oldest first, before + * it is gone. TerminalReplayBuffer feeds these to a DEC mode tracker so + * the replay can restore the modes they set (#843). */ + private readonly onEvict?: (text: string) => void, + ) { if (!(cap > 0)) throw new RangeError(`CappedTextBuffer cap must be positive, got ${cap}`) // A sixteenth of the cap bounds overflow loss to ~6% of the replay, with // a floor so small caps (tests, tiny terminals) do not shred every chunk. @@ -80,6 +87,16 @@ export class CappedTextBuffer { // never a slice, which would retain the whole oversized chunk (#321). // Cutting straight from the tail offset copies each byte once. start = surrogateSafeStart(chunk, chunk.length - this.cap) + // Everything retained so far, then the chunk's own discarded head, in + // stream order: the evict hook must see bytes in the order the + // program wrote them. + if (this.onEvict) { + for (let index = this.head; index < this.pieces.length; index += 1) { + const piece = this.pieces[index]! + if (piece.length > 0) this.onEvict(piece) + } + if (start > 0) this.onEvict(chunk.slice(0, start)) + } this.pieces = [] this.head = 0 this.live = 0 @@ -116,6 +133,7 @@ export class CappedTextBuffer { this.pieces[this.head] = '' this.head += 1 this.live -= dropped.length + this.onEvict?.(dropped) } } } diff --git a/src/main/sessions/decModeTracker.ts b/src/main/sessions/decModeTracker.ts new file mode 100644 index 000000000..796369a6d --- /dev/null +++ b/src/main/sessions/decModeTracker.ts @@ -0,0 +1,175 @@ +// The DEC private modes a terminal replay must restore before its bytes +// (#843). +// +// WHY this exists: SessionManager replays the last N KiB of a PTY to a +// terminal that attaches later (CappedTextBuffer). A full-screen TUI writes its +// mode preamble ONCE at startup. OpenCode 1.18.31, recorded in +// testing/fixtures/terminal-replay-modes/, writes: +// +// ?1049h (alternate screen), ?2004h, ?1000h ?1002h ?1003h (mouse), ?1006h +// +// It then repaints whole frames at up to 60 fps, so the 512 KiB cap evicts +// that preamble within minutes. A freshly constructed xterm then sits on the +// NORMAL buffer with no mouse tracking while the application believes the +// opposite, and the mouse wheel reaches nobody. The pane still looks right, +// because a full-frame repaint renders the same on either buffer. +// +// WHY the state AT THE REPLAY START, fed from the EVICTED bytes: a first fix +// tracked modes as chunks passed and prepended the CURRENT modes. It was +// reverted after review (docs/superpowers/research/ +// 2026-09-08-post-merge-regression-audit.md). If the retained bytes still +// contain output on the normal buffer followed by a later ?1049h, prefixing +// the current ?1049h moves that earlier output onto the alternate buffer, and +// the replay's own ?1049l then throws it away. The only correct prefix is +// what the evicted prefix left behind; the retained bytes then evolve it +// exactly as they did live. +// +// WHY a state machine and not a set of flags (the same review): mouse +// protocols are mutually exclusive in xterm. ?1000h ?1003h leaves ANY; +// ?1003h then ?1000h leaves VT200; and resetting ANY protocol code turns +// tracking off (xterm.js InputHandler.resetModePrivate). Encodings behave the +// same way. `ESC c` (RIS) resets them (see reset() for the one flag xterm +// keeps). 47, 1047 and 1049 all select the alternate buffer. +// +// Deliberately NOT tracked: +// - ?2026 (synchronized output) is per-frame. A prefix could leave the +// terminal holding every paint until a matching ?2026l that the replay +// may not contain. +// - Queries (DECRQM `$p`, DA, `?u`). They ask the terminal to ANSWER, and +// replaying them would send stale answers back to the program. + +type MouseProtocol = 9 | 1000 | 1002 | 1003 +type MouseEncoding = 1006 | 1016 + +const MOUSE_PROTOCOLS: ReadonlySet = new Set([9, 1000, 1002, 1003]) +// Only the encodings xterm.js implements: 1005 (UTF-8) and 1015 (urxvt) are +// logged and ignored there (#2507), so tracking them would only prefix a +// sequence that changes nothing, or wrongly replace a real SGR state. +const MOUSE_ENCODINGS: ReadonlySet = new Set([1006, 1016]) +const ALTERNATE_SCREEN: ReadonlySet = new Set([47, 1047, 1049]) +/** Simple on/off modes with xterm's defaults: application cursor keys (1), + * cursor visible (25), focus reporting (1004), bracketed paste (2004) and + * theme-change notifications (2031). OpenCode sets 2031 once at startup and + * switches its own light/dark palette on the reports; the app pushes theme + * changes into xterm, which reports them only while 2031 is on (#1041 + * review). Setting it sends nothing by itself, so it is safe to prefix. */ +const FLAG_DEFAULTS: ReadonlyMap = new Map([[1, false], [25, true], [1004, false], [2004, false], [2031, false]]) + +/** An unterminated sequence longer than this is not a mode sequence and is + * dropped rather than carried forever. */ +const MAX_CARRY = 64 + +export class DecModeTracker { + private alternate = false + private protocol: MouseProtocol | null = null + private encoding: MouseEncoding | null = null + private readonly flags = new Map(FLAG_DEFAULTS) + // An escape sequence cut by a chunk boundary. Evicted pieces arrive in + // stream order, so the next feed completes it. + private carry = '' + + feed(text: string): void { + const input = this.carry + text + this.carry = '' + let index = input.indexOf('\x1b') + while (index !== -1) { + const next = input[index + 1] + if (next === undefined) { + this.carry = flatCopy(input.slice(index)) + return + } + if (next === 'c') { + this.reset() + index = input.indexOf('\x1b', index + 2) + continue + } + if (next === '[' && input[index + 2] === '?') { + let end = index + 3 + while (end < input.length && /[0-9;]/.test(input[end]!)) end += 1 + if (end >= input.length) { + if (input.length - index <= MAX_CARRY) this.carry = flatCopy(input.slice(index)) + return + } + const final = input[end] + if (final === 'h' || final === 'l') { + for (const param of input.slice(index + 3, end).split(';')) { + if (param !== '') this.apply(Number(param), final === 'h') + } + } + // An ESC that cut this sequence short starts the NEXT one: xterm + // aborts the unfinished sequence and parses the ESC afresh, so the + // search must restart ON it, not after it (#1041 review). + index = input.indexOf('\x1b', final === '\x1b' ? end : end + 1) + continue + } + if (next === '[' && input[index + 2] === undefined) { + this.carry = flatCopy(input.slice(index)) + return + } + index = input.indexOf('\x1b', index + 1) + } + } + + /** The unfinished escape sequence at the very end of the evicted bytes. + * Those bytes sit immediately before the retained tail, so a replay that + * puts this between the prefix and the tail is byte-faithful where the + * sequence straddles the boundary. Without it xterm prints the tail's half + * (`49h`) as text until the next eviction completes it (#1041 review). */ + pendingFragment(): string { + return this.carry + } + + /** The sequences that put a fresh xterm into the tracked state. Empty when + * the state is xterm's default, so a session that never set a mode replays + * byte-for-byte as before. */ + prefix(): string { + let out = '' + if (this.alternate) out += '\x1b[?1049h' + if (this.protocol !== null) out += `\x1b[?${this.protocol}h` + if (this.encoding !== null) out += `\x1b[?${this.encoding}h` + for (const [mode, value] of this.flags) { + if (value !== FLAG_DEFAULTS.get(mode)) out += `\x1b[?${mode}${value ? 'h' : 'l'}` + } + return out + } + + private apply(mode: number, set: boolean): void { + if (ALTERNATE_SCREEN.has(mode)) { + this.alternate = set + } else if (MOUSE_PROTOCOLS.has(mode)) { + // Resetting ANY protocol code disables tracking, as xterm.js does. + this.protocol = set ? mode as MouseProtocol : null + } else if (MOUSE_ENCODINGS.has(mode)) { + // Resetting EITHER encoding returns to the default one, whichever is + // active (xterm.js resetModePrivate: `case 1006: case 1016:`). + this.encoding = set ? mode as MouseEncoding : null + } else if (FLAG_DEFAULTS.has(mode)) { + this.flags.set(mode, set) + } + } + + /** + * `ESC c` (RIS), matched to the SHIPPED xterm 6.1 beta rather than to the + * spec: it leaves the cursor-visibility flag (25) as it was, so a hidden + * cursor stays hidden (#1041 review, reproduced against the bundle). The + * replay must show what a live terminal shows. + * + * Soft reset (DECSTR, `CSI ! p`) is NOT modelled. In xterm it clears 1, + * 1004 and 2004 and shows the cursor. Only `tput init`, `tset` and `reset` + * send it (and `reset` sends RIS first); OpenCode, Codex and Claude never + * do, per a binary grep. Model it if a recording ever shows one. + */ + private reset(): void { + this.alternate = false + this.protocol = null + this.encoding = null + for (const [mode, value] of FLAG_DEFAULTS) if (mode !== 25) this.flags.set(mode, value) + } +} + +// The carry is at most a few dozen characters, but a V8 slice of an evicted +// multi-megabyte chunk would keep that whole chunk alive (#321, and the same +// idiom as cappedTextBuffer.ts). Force a flat copy. +function flatCopy(text: string): string { + return Buffer.from(text, 'utf16le').toString('utf16le') +} diff --git a/src/main/sessions/terminalReplayBuffer.ts b/src/main/sessions/terminalReplayBuffer.ts new file mode 100644 index 000000000..f20fe596b --- /dev/null +++ b/src/main/sessions/terminalReplayBuffer.ts @@ -0,0 +1,43 @@ +import { CappedTextBuffer } from '@main/sessions/cappedTextBuffer.js' +import { DecModeTracker } from '@main/sessions/decModeTracker.js' + +/** + * A PTY replay buffer that remembers the terminal modes its evicted bytes set + * (#843). + * + * `read()` is the retained tail, byte-exact. Paged raw reads use it + * (`sessions.terminalRead` cursors are offsets into it). `replay()` is what a + * newly attached xterm is fed: the DEC private-mode state as of the tail's + * first byte, then the tail. A full-screen TUI such as OpenCode writes its + * alternate-screen and mouse modes once at startup, and the cap evicts them + * within minutes of repainting. Without the prefix a remounted pane showed the + * right picture on the wrong buffer, with no mouse tracking, so the wheel did + * nothing. See DecModeTracker for why the prefix must be the state at the + * replay START. + */ +export class TerminalReplayBuffer { + private readonly modes = new DecModeTracker() + private readonly tail: CappedTextBuffer + + constructor(readonly cap: number, pieceSize?: number) { + this.tail = new CappedTextBuffer(cap, pieceSize, text => this.modes.feed(text)) + } + + get length(): number { + return this.tail.length + } + + append(chunk: string): void { + this.tail.append(chunk) + } + + /** The retained tail exactly as written. */ + read(): string { + return this.tail.read() + } + + /** What an attaching terminal must be fed to reach the program's state. */ + replay(): string { + return this.modes.prefix() + this.modes.pendingFragment() + this.tail.read() + } +} diff --git a/src/main/sessions/terminalReplayModes.test.ts b/src/main/sessions/terminalReplayModes.test.ts new file mode 100644 index 000000000..5334f56d1 --- /dev/null +++ b/src/main/sessions/terminalReplayModes.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' + +import { TerminalReplayBuffer } from './terminalReplayBuffer' + +// #843: an OpenCode terminal pane that remounts can no longer scroll. +// +// The replay a remounted xterm is fed is the capped TAIL of the PTY stream, +// and OpenCode writes its alternate-screen and mouse modes only once at +// startup. Every case runs the bytes through a REAL xterm (headless build) and +// compares the replayed terminal with one that saw the whole live stream, +// which is the only ground truth that matters. +// +// The OpenCode bytes are the real 1.18.31 startup recording +// (testing/fixtures/terminal-replay-modes). Its first chunk is the preamble; +// the rest are full-frame repaints, repeated here until the cap evicts the +// preamble, as minutes of real repainting do. + +const recording = JSON.parse(readFileSync( + resolve(__dirname, '../../../testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json'), 'utf8', +)) as Array<{ t: number; d: string }> +const preambleIndex = recording.findIndex(chunk => chunk.d.includes('\x1b[?1049h')) +const frames = recording.slice(preambleIndex + 1).map(chunk => chunk.d).filter(chunk => chunk.includes('\x1b[?2026h')) + +async function terminalFed(data: string): Promise { + const terminal = new Terminal({ cols: 120, rows: 36, allowProposedApi: true }) + await new Promise(done => terminal.write(data, done)) + return terminal +} + +function observable(terminal: Terminal) { + return { + buffer: terminal.buffer.active.type, + mouse: terminal.modes.mouseTrackingMode, + bracketedPaste: terminal.modes.bracketedPasteMode, + applicationCursorKeys: terminal.modes.applicationCursorKeysMode, + } +} + +async function replayMatchesLive(stream: string[], cap: number, pieceSize?: number) { + const buffer = new TerminalReplayBuffer(cap, pieceSize) + for (const chunk of stream) buffer.append(chunk) + const live = await terminalFed(stream.join('')) + const replayed = await terminalFed(buffer.replay()) + return { live: observable(live), replayed: observable(replayed), buffer, liveTerminal: live, replayedTerminal: replayed } +} + +describe('replay restores the terminal modes the cap evicted (#843)', () => { + it('the recorded OpenCode session: after the preamble is evicted, the replay is on the alternate screen with any-event mouse tracking', async () => { + expect(preambleIndex).toBeGreaterThanOrEqual(0) + expect(frames.length).toBeGreaterThan(0) + const stream = recording.slice(0, preambleIndex + 1).map(chunk => chunk.d) + // A 16 KiB cap and enough recorded frames to push the preamble out many + // times over, the same thing 512 KiB and minutes of 60 fps repaint do. + while (stream.join('').length < 64 * 1024) stream.push(...frames) + const { live, replayed, buffer } = await replayMatchesLive(stream, 16 * 1024) + expect(buffer.read()).not.toContain('\x1b[?1049h') + expect(live).toMatchObject({ buffer: 'alternate', mouse: 'any', bracketedPaste: true }) + // Without the prefix a remounted pane was on the NORMAL buffer with mouse + // tracking off, so xterm never turned the wheel into mouse reports. + expect(replayed).toEqual(live) + }) + + it('output written on the normal screen BEFORE a retained ?1049h stays on the normal screen', async () => { + // The reverted first fix prefixed the CURRENT modes: the replayed shell + // output then landed on the alternate buffer, and the retained ?1049h + // wiped it, so the normal screen lost it. Only evicted bytes decide the + // prefix. + // The stream ENDS on the alternate screen: that is when the "current + // modes" prefix is ?1049h and does the damage (the reviewed failure). + const stream = [ + 'x'.repeat(300), + 'shell line one\r\nshell line two\r\n', + '\x1b[?1049h\x1b[?1003h\x1b[?1006h', + 'full screen app\r\n', + ] + const { live, replayed, liveTerminal, replayedTerminal } = await replayMatchesLive(stream, 200, 50) + expect(replayed).toEqual(live) + const lines = (terminal: Terminal) => Array.from({ length: terminal.buffer.normal.length }, (_, index) => + terminal.buffer.normal.getLine(index)?.translateToString(true) ?? '').filter(Boolean) + // Both shell lines survive on the NORMAL buffer, as they did live. (The + // line holding "shell line one" wraps differently, because the cap + // evicted part of the x run before it.) + for (const terminal of [liveTerminal, replayedTerminal]) { + expect(lines(terminal)).toContain('shell line two') + expect(lines(terminal).some(line => line.endsWith('shell line one'))).toBe(true) + } + }) + + it.each([ + ['?1000h then ?1003h leaves any-event', '\x1b[?1000h\x1b[?1003h', 'any'], + ['?1003h then ?1000h leaves vt200', '\x1b[?1003h\x1b[?1000h', 'vt200'], + ['resetting ANY protocol code turns tracking off', '\x1b[?1000h\x1b[?1003h\x1b[?1000l', 'none'], + ['ESC c resets everything', '\x1b[?1049h\x1b[?1003h\x1bc', 'none'], + ])('mouse protocols are one exclusive state, as in xterm: %s', async (_label, modes, expected) => { + const { live, replayed } = await replayMatchesLive([modes, 'y'.repeat(400)], 200, 50) + expect(live.mouse).toBe(expected) + expect(replayed).toEqual(live) + }) + + it('a mode sequence cut by an eviction boundary is still applied', async () => { + // Pieces of 50 characters put the boundary inside `ESC [ ? 1 0 0 3 h`. + const stream = ['z'.repeat(47) + '\x1b[?1003h', 'w'.repeat(400)] + const { live, replayed } = await replayMatchesLive(stream, 200, 50) + expect(live.mouse).toBe('any') + expect(replayed).toEqual(live) + }) + + it('a mode sequence straddling the boundary is replayed whole, not printed as text', async () => { + // Pieces of 50 put the boundary between `ESC [ ? 1 0` (evicted) and + // `4 9 h` (kept). Without the held fragment the replay printed "49h". + const stream = ['a'.repeat(46) + '\x1b[?10', '49h' + 'b'.repeat(47), 'c'.repeat(150)] + const { live, replayed, replayedTerminal } = await replayMatchesLive(stream, 160, 50) + expect(live.buffer).toBe('alternate') + expect(replayed).toEqual(live) + const text = Array.from({ length: replayedTerminal.buffer.active.length }, (_, index) => replayedTerminal.buffer.active.getLine(index)?.translateToString(true) ?? '').join('') + expect(text).not.toContain('49h') + }) + + it('an ESC that cuts a mode sequence short starts the next one, as xterm parses it', async () => { + const { live, replayed } = await replayMatchesLive(['\x1b[?10\x1b[?1003h', 'd'.repeat(400)], 200, 50) + expect(live.mouse).toBe('any') + expect(replayed).toEqual(live) + }) + + it('a session that never set a mode replays byte for byte as before', () => { + const buffer = new TerminalReplayBuffer(100, 20) + for (let index = 0; index < 20; index += 1) buffer.append(`plain line ${index}\r\n`) + expect(buffer.replay()).toBe(buffer.read()) + }) +}) diff --git a/testing/fixtures/terminal-replay-modes/README.md b/testing/fixtures/terminal-replay-modes/README.md new file mode 100644 index 000000000..c28f5c771 --- /dev/null +++ b/testing/fixtures/terminal-replay-modes/README.md @@ -0,0 +1,9 @@ +# Terminal replay mode fixtures (#843) + +| File | Source | +|---|---| +| `opencode-1.18.31-startup.json` | The first 6 s of PTY output from the installed OpenCode CLI (1.18.31), recorded on 2026-09-19. It was spawned under node-pty at 120×36 with `TERM=xterm-256color` in an empty folder. Each entry is `{ t, d }`: milliseconds since spawn, and the decoded chunk exactly as node-pty delivered it. The only edit replaces the user name with `user`. The TUI prints its working folder, a temporary scratch folder, wrapped across screen rows with cursor moves between the pieces, so no string replacement can match it; that non-sensitive path is left as recorded. | + +The first chunk carries OpenCode's one-time terminal setup: `?1049h` (alternate screen), `?2004h`, `?1000h ?1002h ?1003h` (mouse), and `?1006h` (SGR encoding), along with capability queries. Later chunks are synchronized-output (`?2026h … ?2026l`) full-frame repaints of the start screen. + +Re-record with the same method when OpenCode changes its setup sequence. Do not hand-edit the bytes. diff --git a/testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json b/testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json new file mode 100644 index 000000000..57d9a691f --- /dev/null +++ b/testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json @@ -0,0 +1 @@ +[{"t":1307,"d":"\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[>0q\u001b[?25l\u001b[s\u001b[6n\u001bP+q4d73\u001b\\\u001b[?1016$p\u001b[?2027$p\u001b[?2031$p\u001b[?1004$p\u001b[?2004$p\u001b[?2026$p\u001b[?u\u001b]99;i=opentui-notifications:p=?;\u001b\\\u001b]1337;Capabilities\u001b\\\u001b[H\u001b]66;w=1; \u001b\\\u001b[6n\u001b[H\u001b]66;s=2; \u001b\\\u001b[6n\u001b[u\u001b[s\u001b[?1049h\u001b[>4;1m\u001b[?2027h\u001b[?2004h\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\u001b[14t"},{"t":1326,"d":"\u001b]4;0;?\u0007"},{"t":3881,"d":"\u001b[?2026h\u001b[?25l\u001b[1;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[2;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[3;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[4;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[5;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[6;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[7;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m "},{"t":3881,"d":" \u001b[8;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[9;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[10;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[11;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[12;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[13;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m "},{"t":3882,"d":" \u001b[14;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[15;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[16;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[17;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[18;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[19;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m "},{"t":3882,"d":" \u001b[20;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[21;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[22;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[23;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[24;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[25;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[26;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10"},{"t":3882,"d":"m \u001b[27;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[28;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[29;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[30;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[31;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[32;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m "},{"t":3882,"d":" \u001b[33;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[34;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[35;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[36;1H\u001b[38;2;255;255;255m\u001b[48;2;10;10;10m \u001b[0m\u001b[?2026l"},{"t":3931,"d":"\u001b]0;OpenCode\u0007"},{"t":4018,"d":"\u001b[?2026h\u001b[?25l\u001b[12;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m \u001b[0m\u001b[12;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m \u2584 \u001b[0m\u001b[13;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2588\u2580\u2580\u2588 \u2588\u2580\u2580\u2588 \u2588\u2580\u2580\u2588 \u2588\u2580\u2580\u2584\u001b[0m\u001b[13;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m\u2588\u2580\u2580\u2580 \u2588\u2580\u2580\u2588 \u2588\u2580\u2580\u2588 \u2588\u2580\u2580\u2588\u001b[0m\u001b[14;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2588\u001b[0m\u001b[14;43H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m \u001b[0m\u001b[14;45H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2588 \u2588\u001b[0m\u001b[14;48H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m \u001b[0m\u001b[14;50H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2588 \u2588\u001b[0m\u001b[14;53H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m\u2580\u2580\u2580\u001b[0m\u001b[14;56H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m \u2588\u001b[0m\u001b[14;58H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m \u001b[0m\u001b[14;60H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2588\u001b[0m\u001b[14;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m\u2588\u001b[0m\u001b[14;63H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m \u001b[0m\u001b[14;66H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m \u2588\u001b[0m\u001b[14;68H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m"},{"t":4018,"d":" \u001b[0m\u001b[14;70H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m\u2588 \u2588\u001b[0m\u001b[14;73H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m \u001b[0m\u001b[14;75H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m\u2588 \u2588\u001b[0m\u001b[14;78H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m\u2580\u2580\u2580\u001b[0m\u001b[15;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2580\u2580\u2580\u2580 \u2588\u2580\u2580\u2580 \u2580\u2580\u2580\u2580 \u2580\u001b[0m\u001b[15;58H\u001b[38;2;40;40;40m\u001b[48;2;10;10;10m\u2580\u2580\u001b[0m\u001b[15;60H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m\u2580\u001b[0m\u001b[15;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m\u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580\u001b[0m\u001b[18;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m\u2503\u001b[0m\u001b[18;25H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[19;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m\u2503\u001b[0m\u001b[19;25H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[19;27H\u001b[38;2;128;128;128m\u001b[48;2;30;30;30mAsk anything\u2026 \"Fix broken tests\"\u001b[0m\u001b[19;59H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[20;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m\u2503\u001b[0m\u001b[20;25H\u001b[38;"},{"t":4020,"d":"2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m\u2503\u001b[0m\u001b[21;25H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;27H\u001b[38;2;92;156;245m\u001b[48;2;30;30;30mBuild\u001b[0m\u001b[21;32H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;33H\u001b[38;2;128;128;128m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;34H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;35H\u001b[38;2;238;238;238m\u001b[48;2;30;30;30mGLM-5.3 Highspeed\u001b[0m\u001b[21;52H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;53H\u001b[38;2;128;128;128m\u001b[48;2;30;30;30mZ.AI Coding Plan\u001b[0m\u001b[21;69H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[22;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m\u2579\u001b[0m\u001b[22;25H\u001b[38;2;30;30;30m\u001b[48;2;10;10;10m\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u001b[0m\u001b[23;72H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10mtab \u001b[0m\u001b[23"},{"t":4020,"d":";76H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10magents\u001b[0m\u001b[23;84H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10mctrl+p \u001b[0m\u001b[23;91H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10mcommands\u001b[0m\u001b[34;3H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m/private/tmp/claude-501/-Users-user-Desktop-Development-agent-code/83a02301-2e9a-42a6-98cb-\u001b[0m\u001b[34;112H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m1.18.31\u001b[0m\u001b[35;3H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m38cffc008e25/scratchpad/opencode-pty/cwd\u001b[0m\u001b[0m\u001b]12;#eeeeee\u0007\u001b[1 q\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4063,"d":"\u001b[?2026h\u001b[?25l\u001b[21;42H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[21;43H\u001b[38;2;128;128;128m\u001b[48;2;30;30;30mZ.AI Coding Plan\u001b[0m\u001b[21;59H\u001b[38;2;255;255;255m\u001b[48;2;30;30;30m \u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4092,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;45;45;45m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;62;50;35m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4112,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;70;70;70m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;119;86;45m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4142,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;88;88;88m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;158;112;51m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4164,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;113;113;113m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;213;147;61m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4187,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;125;125;125m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;238;163;65m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"},{"t":4209,"d":"\u001b[?2026h\u001b[?25l\u001b[21;60H\u001b[38;2;128;128;128m\u001b[48;2;30;30;30m\u00b7\u001b[0m\u001b[21;62H\u001b[38;2;245;167;66m\u001b[48;2;30;30;30m\u001b[1mmax\u001b[0m\u001b[0m\u001b[19;27H\u001b[?25h\u001b[?2026l"}] \ No newline at end of file