From 227eb1db03a2da5b13b52a1fb5743d8f9858e982 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 19 Sep 2026 01:33:52 -0700 Subject: [PATCH 1/3] test(terminal): a replay must restore the modes the cap evicted (#843) Real OpenCode 1.18.31 startup bytes (recorded under node-pty and sanitized) show the one-time preamble: ?1049h, ?2004h, ?1000h ?1002h ?1003h and ?1006h. Every case runs through a real headless xterm and compares the replayed terminal with one that saw the live stream: - the recording, with the preamble evicted, still replays onto the alternate screen with any-event mouse tracking. This is checked for the buffer on its own and through SessionManager.attachAgentPty at the real 512 KiB cap; - normal-screen output before a retained ?1049h stays on the normal screen, which is the case the reverted "current modes" fix broke; - mouse protocols follow xterm's exclusive state (1000 then 1003, 1003 then 1000, reset, ESC c); - a sequence cut by an eviction boundary still counts; - a session that set no mode replays byte for byte. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/sessionManager.screenGate.test.ts | 25 ++++ src/main/sessions/terminalReplayModes.test.ts | 116 ++++++++++++++++++ .../fixtures/terminal-replay-modes/README.md | 9 ++ .../opencode-1.18.31-startup.json | 1 + 4 files changed, 151 insertions(+) create mode 100644 src/main/sessions/terminalReplayModes.test.ts create mode 100644 testing/fixtures/terminal-replay-modes/README.md create mode 100644 testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json 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/sessions/terminalReplayModes.test.ts b/src/main/sessions/terminalReplayModes.test.ts new file mode 100644 index 000000000..43dd8d3b8 --- /dev/null +++ b/src/main/sessions/terminalReplayModes.test.ts @@ -0,0 +1,116 @@ +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 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..473b19f54 --- /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 edits are the folder path, the home directory and the user name, which are replaced with placeholders. | + +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..bdb459ef8 --- /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 ▄ \u001b[0m\u001b[13;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m█▀▀█ █▀▀█ █▀▀█ █▀▀▄\u001b[0m\u001b[13;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m█▀▀▀ █▀▀█ █▀▀█ █▀▀█\u001b[0m\u001b[14;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m█\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█ █\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█ █\u001b[0m\u001b[14;53H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m▀▀▀\u001b[0m\u001b[14;56H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m █\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█\u001b[0m\u001b[14;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m█\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 █\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█ █\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█ █\u001b[0m\u001b[14;78H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m▀▀▀\u001b[0m\u001b[15;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀\u001b[0m\u001b[15;58H\u001b[38;2;40;40;40m\u001b[48;2;10;10;10m▀▀\u001b[0m\u001b[15;60H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m▀\u001b[0m\u001b[15;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀\u001b[0m\u001b[18;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m┃\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┃\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… \"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┃\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┃\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·\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╹\u001b[0m\u001b[22;25H\u001b[38;2;30;30;30m\u001b[48;2;10;10;10m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\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·\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·\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·\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·\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·\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·\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 From 10f3672c69d43cf77f2dc2373c3907a2c1e5ead3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 19 Sep 2026 01:33:52 -0700 Subject: [PATCH 2/3] fix(terminal): replay restores the DEC modes the capped buffer evicted (#843) A full-screen TUI writes its alternate-screen and mouse modes once at startup, and 512 KiB of 60 fps repaint evicts them within minutes. A remounted xterm was fed only the tail. It sat on the normal buffer with mouse tracking off, so the wheel did nothing, and the pane looked fine because a full-frame repaint renders the same on either buffer. CappedTextBuffer now reports what it evicts. A DecModeTracker, which models xterm's exclusive mouse protocols and encodings, the 47/1047/1049 alternate-screen aliases, ESC c, and cursor keys, cursor visibility, focus and bracketed paste, folds those evicted bytes into the state as of the replay's first byte. TerminalReplayBuffer.replay() prefixes that state, and both attach paths use it. read() stays byte-exact for the paged raw reads. This is the state at the replay START, not the current state: the first fix prefixed current modes and was reverted, because it moved retained normal-screen output onto the alternate buffer (see the 2026-09-08 regression audit). Synchronized output (2026) and terminal queries are deliberately not replayed. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/sessionManager.ts | 20 +-- src/main/sessions/cappedTextBuffer.ts | 20 ++- src/main/sessions/decModeTracker.ts | 148 ++++++++++++++++++++++ src/main/sessions/terminalReplayBuffer.ts | 43 +++++++ 4 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 src/main/sessions/decModeTracker.ts create mode 100644 src/main/sessions/terminalReplayBuffer.ts 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..4817b1b64 --- /dev/null +++ b/src/main/sessions/decModeTracker.ts @@ -0,0 +1,148 @@ +// 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 everything. 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) and bracketed paste (2004). */ +const FLAG_DEFAULTS: ReadonlyMap = new Map([[1, false], [25, true], [1004, false], [2004, 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') + } + } + index = input.indexOf('\x1b', end + 1) + continue + } + if (next === '[' && input[index + 2] === undefined) { + this.carry = flatCopy(input.slice(index)) + return + } + index = input.indexOf('\x1b', index + 1) + } + } + + /** 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) + } + } + + private reset(): void { + this.alternate = false + this.protocol = null + this.encoding = null + for (const [mode, value] of FLAG_DEFAULTS) 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..e3154c238 --- /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.tail.read() + } +} From 37e83e08bd414dc527dce9999ad3eeb1690c3bea Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 19 Sep 2026 01:52:12 -0700 Subject: [PATCH 3/3] fix(terminal): review follow-ups on the replay mode prefix (#1041) From the #1041 review (APPROVE, low findings, all reproduced there): - ?2031 (theme-change notifications) is tracked. OpenCode sets it once, and without it a remounted pane stopped following app theme switches. - A sequence straddling the eviction boundary is replayed whole: the held fragment goes between the prefix and the tail, instead of xterm printing "49h". - An ESC that cuts a private-mode sequence short now starts the next sequence, as xterm parses it. - ESC c keeps a hidden cursor hidden, matching the shipped xterm. Soft reset (DECSTR) is documented as not modelled. - The shell attach path has a test at the real 256 KiB cap. Reverting attachTerminal to read() had passed every test. - The fixture README states exactly what was edited: the user name. The wrapped temporary path could not be replaced. Co-Authored-By: Claude Opus 5 (1M context) --- .../sessionManager.terminalReplay.test.ts | 48 +++++++++++++++++++ src/main/sessions/decModeTracker.ts | 39 ++++++++++++--- src/main/sessions/terminalReplayBuffer.ts | 2 +- src/main/sessions/terminalReplayModes.test.ts | 17 +++++++ .../fixtures/terminal-replay-modes/README.md | 2 +- .../opencode-1.18.31-startup.json | 2 +- 6 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 src/main/sessionManager.terminalReplay.test.ts 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/sessions/decModeTracker.ts b/src/main/sessions/decModeTracker.ts index 4817b1b64..796369a6d 100644 --- a/src/main/sessions/decModeTracker.ts +++ b/src/main/sessions/decModeTracker.ts @@ -28,8 +28,8 @@ // 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 everything. 47, 1047 and 1049 all select the -// alternate buffer. +// 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 @@ -48,8 +48,12 @@ const MOUSE_PROTOCOLS: ReadonlySet = new Set([9, 1000, 1002, 1003]) 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) and bracketed paste (2004). */ -const FLAG_DEFAULTS: ReadonlyMap = new Map([[1, false], [25, true], [1004, false], [2004, false]]) + * 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. */ @@ -92,7 +96,10 @@ export class DecModeTracker { if (param !== '') this.apply(Number(param), final === 'h') } } - index = input.indexOf('\x1b', end + 1) + // 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) { @@ -103,6 +110,15 @@ export class DecModeTracker { } } + /** 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. */ @@ -132,11 +148,22 @@ export class DecModeTracker { } } + /** + * `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) this.flags.set(mode, value) + for (const [mode, value] of FLAG_DEFAULTS) if (mode !== 25) this.flags.set(mode, value) } } diff --git a/src/main/sessions/terminalReplayBuffer.ts b/src/main/sessions/terminalReplayBuffer.ts index e3154c238..f20fe596b 100644 --- a/src/main/sessions/terminalReplayBuffer.ts +++ b/src/main/sessions/terminalReplayBuffer.ts @@ -38,6 +38,6 @@ export class TerminalReplayBuffer { /** What an attaching terminal must be fed to reach the program's state. */ replay(): string { - return this.modes.prefix() + this.tail.read() + 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 index 43dd8d3b8..5334f56d1 100644 --- a/src/main/sessions/terminalReplayModes.test.ts +++ b/src/main/sessions/terminalReplayModes.test.ts @@ -108,6 +108,23 @@ describe('replay restores the terminal modes the cap evicted (#843)', () => { 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`) diff --git a/testing/fixtures/terminal-replay-modes/README.md b/testing/fixtures/terminal-replay-modes/README.md index 473b19f54..c28f5c771 100644 --- a/testing/fixtures/terminal-replay-modes/README.md +++ b/testing/fixtures/terminal-replay-modes/README.md @@ -2,7 +2,7 @@ | 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 edits are the folder path, the home directory and the user name, which are replaced with placeholders. | +| `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. 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 index bdb459ef8..57d9a691f 100644 --- a/testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json +++ b/testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json @@ -1 +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 ▄ \u001b[0m\u001b[13;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m█▀▀█ █▀▀█ █▀▀█ █▀▀▄\u001b[0m\u001b[13;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m█▀▀▀ █▀▀█ █▀▀█ █▀▀█\u001b[0m\u001b[14;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m█\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█ █\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█ █\u001b[0m\u001b[14;53H\u001b[38;2;128;128;128m\u001b[48;2;40;40;40m▀▀▀\u001b[0m\u001b[14;56H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m █\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█\u001b[0m\u001b[14;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m█\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 █\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█ █\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█ █\u001b[0m\u001b[14;78H\u001b[38;2;238;238;238m\u001b[48;2;67;67;67m\u001b[1m▀▀▀\u001b[0m\u001b[15;42H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀\u001b[0m\u001b[15;58H\u001b[38;2;40;40;40m\u001b[48;2;10;10;10m▀▀\u001b[0m\u001b[15;60H\u001b[38;2;128;128;128m\u001b[48;2;10;10;10m▀\u001b[0m\u001b[15;62H\u001b[38;2;238;238;238m\u001b[48;2;10;10;10m\u001b[1m▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀\u001b[0m\u001b[18;24H\u001b[38;2;92;156;245m\u001b[48;2;10;10;10m┃\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┃\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… \"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┃\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┃\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·\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╹\u001b[0m\u001b[22;25H\u001b[38;2;30;30;30m\u001b[48;2;10;10;10m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\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·\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·\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·\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·\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·\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·\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 +[{"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