From 7e5c3eeb0832936b450ebdee1c6258f4d73f3b21 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:55:34 +0000 Subject: [PATCH 1/9] test(js): reproduce leading repaint state loss --- js/tests/fixtures/tui-leading-repaint-fixture.mjs | 8 ++++++++ js/tests/terminal-capture.test.mjs | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 js/tests/fixtures/tui-leading-repaint-fixture.mjs diff --git a/js/tests/fixtures/tui-leading-repaint-fixture.mjs b/js/tests/fixtures/tui-leading-repaint-fixture.mjs new file mode 100644 index 0000000..19bf1db --- /dev/null +++ b/js/tests/fixtures/tui-leading-repaint-fixture.mjs @@ -0,0 +1,8 @@ +const clear = '\u001b[2J\u001b[H'; +const pause = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +process.stdout.write(`${clear}first-state`); +await pause(20); +process.stdout.write(`${clear}second-state`); +await pause(20); diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index d1ad083..3cde353 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -113,6 +113,18 @@ describe('PTY terminal capture', () => { } }); + test('retains a state before a later output chunk starts with a repaint', async () => { + const capture = await captureTerminal({ + file: process.execPath, + args: [join(directory, 'fixtures/tui-leading-repaint-fixture.mjs')], + cols: 20, + rows: 3, + settleMilliseconds: 100, + }); + + expect(capture.transcript).toBe('first-state\nsecond-state'); + }); + test('retains repeated content when another state appeared between it', () => { const frame = (lines) => ({ lines }); expect( From d9171868cf85666f0a12b17107b906e3866fbe83 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:03:16 +0000 Subject: [PATCH 2/9] fix(js): preserve states before leading repaint --- js/.changeset/tender-terminals-repaint.md | 6 ++++++ js/src/terminal-capture.mjs | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 js/.changeset/tender-terminals-repaint.md diff --git a/js/.changeset/tender-terminals-repaint.md b/js/.changeset/tender-terminals-repaint.md new file mode 100644 index 0000000..99faae6 --- /dev/null +++ b/js/.changeset/tender-terminals-repaint.md @@ -0,0 +1,6 @@ +--- +'command-stream': patch +--- + +Preserve the current terminal frame before a later output chunk begins with a +full-screen repaint. diff --git a/js/src/terminal-capture.mjs b/js/src/terminal-capture.mjs index 3b531a2..ede4ff1 100644 --- a/js/src/terminal-capture.mjs +++ b/js/src/terminal-capture.mjs @@ -234,13 +234,12 @@ export const captureTerminal = async ({ const frames = []; let output = ''; let interactionIndex = 0; - let settleTimer; - let stopTimer; + let settleTimer, stopTimer; let stopMarkerSeen = false; let writeQueue = Promise.resolve(); + let terminalHasOutput = false; let interactionScheduled = false; let captureError; - const appendFrame = () => { const frame = terminalFrame(terminal, elapsed); if (!frames.at(-1) || !sameFrame(frames.at(-1), frame)) { @@ -253,6 +252,9 @@ export const captureTerminal = async ({ }; const queueOutput = (data) => { const segments = splitRenderSegments(data); + if (terminalHasOutput && data.startsWith(CLEAR_SCREEN)) { + writeQueue = writeQueue.then(appendFrame); + } for (const [index, segment] of segments.entries()) { writeQueue = writeQueue.then( () => @@ -260,6 +262,7 @@ export const captureTerminal = async ({ terminal.write(segment, written); }) ); + terminalHasOutput ||= segment.length > 0; if (index < segments.length - 1) { writeQueue = writeQueue.then(appendFrame); } From 2e144e6c48e9223acab90b94aea682395543da4b Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:06:05 +0000 Subject: [PATCH 3/9] test(rust): reproduce leading repaint state loss --- rust/tests/terminal_capture.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs index da62a96..e3266c7 100644 --- a/rust/tests/terminal_capture.rs +++ b/rust/tests/terminal_capture.rs @@ -87,6 +87,19 @@ printf '\033[2J\033[Halpha\n' assert_eq!(capture.transcript, "alpha\nbeta\nalpha"); } +#[test] +fn retains_a_state_before_a_later_output_chunk_starts_with_a_repaint() { + let mut options = shell_options( + "printf '\\033[2J\\033[Hfirst-state'; sleep 0.02; \ + printf '\\033[2J\\033[Hsecond-state'", + ); + options.settle_duration = Duration::from_millis(100); + + let capture = capture_terminal(options).expect("capture should complete"); + + assert_eq!(capture.transcript, "first-state\nsecond-state"); +} + #[test] fn retains_lines_after_they_scroll_off_the_visible_terminal() { let mut options = shell_options( From ef9776e183e20ec52a07cf2e34e40a3117d5014c Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:07:17 +0000 Subject: [PATCH 4/9] fix(rust): preserve states before leading repaint --- rust/changelog.d/20260724_230700_leading_repaint.md | 8 ++++++++ rust/src/terminal/capture.rs | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 rust/changelog.d/20260724_230700_leading_repaint.md diff --git a/rust/changelog.d/20260724_230700_leading_repaint.md b/rust/changelog.d/20260724_230700_leading_repaint.md new file mode 100644 index 0000000..586fdd4 --- /dev/null +++ b/rust/changelog.d/20260724_230700_leading_repaint.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- Preserve the current terminal frame before a later output chunk begins with + a full-screen repaint. diff --git a/rust/src/terminal/capture.rs b/rust/src/terminal/capture.rs index a60d386..1b32431 100644 --- a/rust/src/terminal/capture.rs +++ b/rust/src/terminal/capture.rs @@ -253,6 +253,7 @@ pub fn capture_terminal( let mut recording = asciicast(&options); let mut output = String::new(); let mut frames = Vec::new(); + let mut terminal_has_output = false; let mut interaction_index = 0; let mut last_output = None; let mut dirty = false; @@ -269,8 +270,12 @@ pub fn capture_terminal( record(&mut recording, started, "o", text.into_owned()); let segments = render_segments(&data); let segment_count = segments.len(); + if terminal_has_output && data.starts_with(CLEAR_SCREEN) { + append_frame(&mut frames, &parser, started); + } for (index, segment) in segments.into_iter().enumerate() { parser.process(segment); + terminal_has_output |= !segment.is_empty(); if index + 1 < segment_count { append_frame(&mut frames, &parser, started); } From 6b0b1c571cfe249cb8bc23fbcbb0d7f6f4576bf1 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:09:06 +0000 Subject: [PATCH 5/9] test: reproduce repaint sequence split across chunks --- js/tests/fixtures/tui-leading-repaint-fixture.mjs | 4 +++- js/tests/terminal-capture.test.mjs | 2 +- rust/tests/terminal_capture.rs | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/js/tests/fixtures/tui-leading-repaint-fixture.mjs b/js/tests/fixtures/tui-leading-repaint-fixture.mjs index 19bf1db..5c655a6 100644 --- a/js/tests/fixtures/tui-leading-repaint-fixture.mjs +++ b/js/tests/fixtures/tui-leading-repaint-fixture.mjs @@ -4,5 +4,7 @@ const pause = (milliseconds) => process.stdout.write(`${clear}first-state`); await pause(20); -process.stdout.write(`${clear}second-state`); +process.stdout.write('\u001b[2'); +await pause(20); +process.stdout.write('J\u001b[Hsecond-state'); await pause(20); diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index 3cde353..8181a24 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -113,7 +113,7 @@ describe('PTY terminal capture', () => { } }); - test('retains a state before a later output chunk starts with a repaint', async () => { + test('retains a state when a later repaint is split across output chunks', async () => { const capture = await captureTerminal({ file: process.execPath, args: [join(directory, 'fixtures/tui-leading-repaint-fixture.mjs')], diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs index e3266c7..e0e8caa 100644 --- a/rust/tests/terminal_capture.rs +++ b/rust/tests/terminal_capture.rs @@ -88,10 +88,10 @@ printf '\033[2J\033[Halpha\n' } #[test] -fn retains_a_state_before_a_later_output_chunk_starts_with_a_repaint() { +fn retains_a_state_when_a_later_repaint_is_split_across_output_chunks() { let mut options = shell_options( - "printf '\\033[2J\\033[Hfirst-state'; sleep 0.02; \ - printf '\\033[2J\\033[Hsecond-state'", + "printf '\\033[2J\\033[Hfirst-state'; sleep 0.02; printf '\\033[2'; \ + sleep 0.02; printf 'J\\033[Hsecond-state'", ); options.settle_duration = Duration::from_millis(100); From 92ad5f1821386ffd4c8254508339f2605d21e634 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:11:40 +0000 Subject: [PATCH 6/9] fix: retain states across split repaint sequences --- js/.changeset/tender-terminals-repaint.md | 4 +- js/src/terminal-capture.mjs | 88 +++++++++++++------ .../20260724_230700_leading_repaint.md | 4 +- rust/src/terminal/capture.rs | 17 +++- 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/js/.changeset/tender-terminals-repaint.md b/js/.changeset/tender-terminals-repaint.md index 99faae6..75385b3 100644 --- a/js/.changeset/tender-terminals-repaint.md +++ b/js/.changeset/tender-terminals-repaint.md @@ -2,5 +2,5 @@ 'command-stream': patch --- -Preserve the current terminal frame before a later output chunk begins with a -full-screen repaint. +Preserve the current terminal frame before a later full-screen repaint, +including when its control sequence is split across PTY output chunks. diff --git a/js/src/terminal-capture.mjs b/js/src/terminal-capture.mjs index ede4ff1..6b305b8 100644 --- a/js/src/terminal-capture.mjs +++ b/js/src/terminal-capture.mjs @@ -32,6 +32,62 @@ const splitRenderSegments = (data) => { return segments; }; +const splitPendingRenderSequence = (data, flush) => { + if (!data || flush) { + return [data, '']; + } + const maximum = Math.min(data.length, CLEAR_SCREEN.length - 1); + for (let length = maximum; length > 0; length -= 1) { + if (CLEAR_SCREEN.startsWith(data.slice(-length))) { + return [data.slice(0, -length), data.slice(-length)]; + } + } + return [data, '']; +}; + +const createTerminalOutputWriter = (terminal, appendFrame) => { + let promise = Promise.resolve(); + let pending = ''; + let terminalHasOutput = false; + const after = (operation) => { + promise = promise.then(operation); + return promise; + }; + const write = (data, flush = false) => { + const [complete, nextPending] = splitPendingRenderSequence( + pending + data, + flush + ); + pending = nextPending; + if (!complete) { + return; + } + + const segments = splitRenderSegments(complete); + if (terminalHasOutput && complete.startsWith(CLEAR_SCREEN)) { + after(appendFrame); + } + for (const [index, segment] of segments.entries()) { + after( + () => + new Promise((written) => { + terminal.write(segment, written); + }) + ); + terminalHasOutput ||= segment.length > 0; + if (index < segments.length - 1) { + after(appendFrame); + } + } + }; + return { + after, + flush: () => write('', true), + settled: () => promise, + write, + }; +}; + const normalizeLines = (lines) => { let last = lines.length; while (last > 0 && lines[last - 1] === '') { @@ -236,8 +292,6 @@ export const captureTerminal = async ({ let interactionIndex = 0; let settleTimer, stopTimer; let stopMarkerSeen = false; - let writeQueue = Promise.resolve(); - let terminalHasOutput = false; let interactionScheduled = false; let captureError; const appendFrame = () => { @@ -250,24 +304,7 @@ export const captureTerminal = async ({ clearTimeout(settleTimer); settleTimer = setTimeout(appendFrame, settleMilliseconds); }; - const queueOutput = (data) => { - const segments = splitRenderSegments(data); - if (terminalHasOutput && data.startsWith(CLEAR_SCREEN)) { - writeQueue = writeQueue.then(appendFrame); - } - for (const [index, segment] of segments.entries()) { - writeQueue = writeQueue.then( - () => - new Promise((written) => { - terminal.write(segment, written); - }) - ); - terminalHasOutput ||= segment.length > 0; - if (index < segments.length - 1) { - writeQueue = writeQueue.then(appendFrame); - } - } - }; + const outputWriter = createTerminalOutputWriter(terminal, appendFrame); const advanceInteractions = () => { if ( interactionScheduled || @@ -279,7 +316,7 @@ export const captureTerminal = async ({ interactionScheduled = true; traceCapture('interaction-scheduled', { interactionIndex }); - writeQueue.then(() => { + outputWriter.after(() => { appendFrame(); traceCapture('interaction-applied', { interactionIndex }); applyInteraction({ @@ -307,13 +344,13 @@ export const captureTerminal = async ({ traceCapture('output', { data }); output += data; record('o', data); - queueOutput(data); - writeQueue.then(settle); + outputWriter.write(data); + outputWriter.after(settle); advanceInteractions(); if (stopMarker && output.includes(stopMarker) && !stopMarkerSeen) { stopMarkerSeen = true; - writeQueue.then(appendFrame); + outputWriter.after(appendFrame); stopTimer = setTimeout( () => child.kill('SIGTERM'), stopMarkerGraceMilliseconds @@ -332,7 +369,8 @@ export const captureTerminal = async ({ let status; try { status = await completion; - await writeQueue; + outputWriter.flush(); + await outputWriter.settled(); clearTimeout(settleTimer); appendFrame(); } finally { diff --git a/rust/changelog.d/20260724_230700_leading_repaint.md b/rust/changelog.d/20260724_230700_leading_repaint.md index 586fdd4..5867236 100644 --- a/rust/changelog.d/20260724_230700_leading_repaint.md +++ b/rust/changelog.d/20260724_230700_leading_repaint.md @@ -4,5 +4,5 @@ bump: patch ### Fixed -- Preserve the current terminal frame before a later output chunk begins with - a full-screen repaint. +- Preserve the current terminal frame before a later full-screen repaint, + including when its control sequence is split across PTY output chunks. diff --git a/rust/src/terminal/capture.rs b/rust/src/terminal/capture.rs index 1b32431..969fb7f 100644 --- a/rust/src/terminal/capture.rs +++ b/rust/src/terminal/capture.rs @@ -80,6 +80,15 @@ fn render_segments(data: &[u8]) -> Vec<&[u8]> { segments } +fn drain_complete_render_data(pending: &mut Vec) -> Vec { + let maximum = pending.len().min(CLEAR_SCREEN.len() - 1); + let pending_length = (1..=maximum) + .rev() + .find(|length| CLEAR_SCREEN.starts_with(&pending[pending.len() - length..])) + .unwrap_or(0); + pending.drain(..pending.len() - pending_length).collect() +} + fn record(asciicast: &mut Asciicast, started: Instant, code: &str, data: impl Into) { asciicast.events.push(AsciicastEvent { time: elapsed(started), @@ -253,6 +262,7 @@ pub fn capture_terminal( let mut recording = asciicast(&options); let mut output = String::new(); let mut frames = Vec::new(); + let mut pending_render = Vec::new(); let mut terminal_has_output = false; let mut interaction_index = 0; let mut last_output = None; @@ -268,9 +278,11 @@ pub fn capture_terminal( let text = String::from_utf8_lossy(&data); output.push_str(&text); record(&mut recording, started, "o", text.into_owned()); - let segments = render_segments(&data); + pending_render.extend_from_slice(&data); + let render_data = drain_complete_render_data(&mut pending_render); + let segments = render_segments(&render_data); let segment_count = segments.len(); - if terminal_has_output && data.starts_with(CLEAR_SCREEN) { + if terminal_has_output && render_data.starts_with(CLEAR_SCREEN) { append_frame(&mut frames, &parser, started); } for (index, segment) in segments.into_iter().enumerate() { @@ -343,6 +355,7 @@ pub fn capture_terminal( } } + parser.process(&pending_render); append_frame(&mut frames, &parser, started); let capture = capture_result( status.expect("child status is available after capture loop"), From d7f09af1b6808f4893e27d2928436fc4ef381226 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:15:34 +0000 Subject: [PATCH 7/9] fix: detect repaint at terminal erase boundary --- js/src/terminal-capture.mjs | 12 ++++++------ rust/src/terminal/capture.rs | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/js/src/terminal-capture.mjs b/js/src/terminal-capture.mjs index 6b305b8..d34418f 100644 --- a/js/src/terminal-capture.mjs +++ b/js/src/terminal-capture.mjs @@ -17,15 +17,15 @@ const KEY_SEQUENCES = { TAB: '\t', UP: '\u001b[A', }; -const CLEAR_SCREEN = '\u001b[2J\u001b[H'; +const ERASE_SCREEN = '\u001b[2J'; const splitRenderSegments = (data) => { - const pieces = data.split(CLEAR_SCREEN); + const pieces = data.split(ERASE_SCREEN); if (pieces.length === 1) { return [data]; } - const segments = pieces.slice(1).map((piece) => `${CLEAR_SCREEN}${piece}`); + const segments = pieces.slice(1).map((piece) => `${ERASE_SCREEN}${piece}`); if (pieces[0]) { segments.unshift(pieces[0]); } @@ -36,9 +36,9 @@ const splitPendingRenderSequence = (data, flush) => { if (!data || flush) { return [data, '']; } - const maximum = Math.min(data.length, CLEAR_SCREEN.length - 1); + const maximum = Math.min(data.length, ERASE_SCREEN.length - 1); for (let length = maximum; length > 0; length -= 1) { - if (CLEAR_SCREEN.startsWith(data.slice(-length))) { + if (ERASE_SCREEN.startsWith(data.slice(-length))) { return [data.slice(0, -length), data.slice(-length)]; } } @@ -64,7 +64,7 @@ const createTerminalOutputWriter = (terminal, appendFrame) => { } const segments = splitRenderSegments(complete); - if (terminalHasOutput && complete.startsWith(CLEAR_SCREEN)) { + if (terminalHasOutput && complete.startsWith(ERASE_SCREEN)) { after(appendFrame); } for (const [index, segment] of segments.entries()) { diff --git a/rust/src/terminal/capture.rs b/rust/src/terminal/capture.rs index 969fb7f..21764f8 100644 --- a/rust/src/terminal/capture.rs +++ b/rust/src/terminal/capture.rs @@ -9,7 +9,7 @@ use std::io::{Read, Write}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const CLEAR_SCREEN: &[u8] = b"\x1b[2J\x1b[H"; +const ERASE_SCREEN: &[u8] = b"\x1b[2J"; fn elapsed(started: Instant) -> f64 { (started.elapsed().as_secs_f64() * 1_000_000.0).round() / 1_000_000.0 @@ -61,9 +61,9 @@ fn append_frame(frames: &mut Vec, parser: &vt100::Parser, started fn render_segments(data: &[u8]) -> Vec<&[u8]> { let positions = data - .windows(CLEAR_SCREEN.len()) + .windows(ERASE_SCREEN.len()) .enumerate() - .filter_map(|(index, window)| (window == CLEAR_SCREEN).then_some(index)) + .filter_map(|(index, window)| (window == ERASE_SCREEN).then_some(index)) .collect::>(); if positions.is_empty() { return vec![data]; @@ -81,10 +81,10 @@ fn render_segments(data: &[u8]) -> Vec<&[u8]> { } fn drain_complete_render_data(pending: &mut Vec) -> Vec { - let maximum = pending.len().min(CLEAR_SCREEN.len() - 1); + let maximum = pending.len().min(ERASE_SCREEN.len() - 1); let pending_length = (1..=maximum) .rev() - .find(|length| CLEAR_SCREEN.starts_with(&pending[pending.len() - length..])) + .find(|length| ERASE_SCREEN.starts_with(&pending[pending.len() - length..])) .unwrap_or(0); pending.drain(..pending.len() - pending_length).collect() } @@ -282,7 +282,7 @@ pub fn capture_terminal( let render_data = drain_complete_render_data(&mut pending_render); let segments = render_segments(&render_data); let segment_count = segments.len(); - if terminal_has_output && render_data.starts_with(CLEAR_SCREEN) { + if terminal_has_output && render_data.starts_with(ERASE_SCREEN) { append_frame(&mut frames, &parser, started); } for (index, segment) in segments.into_iter().enumerate() { From fe6cfee20d7887d9c238432c36da86db73ed7f4b Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:19:47 +0000 Subject: [PATCH 8/9] test(js): allow preserved repaint frame on Windows --- js/tests/terminal-capture.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index 8181a24..db13046 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -76,7 +76,7 @@ describe('PTY terminal capture', () => { expect(capture.transcript).toContain('ready:true:20x4'); expect(capture.transcript).toContain('typed:hello'); expect(capture.transcript).toContain('resized:32x6'); - expect(capture.frames.length).toBeLessThan(8); + expect(capture.frames.length).toBeLessThanOrEqual(8); const replay = await readAsciicast(join(artifactDirectory, 'session.cast')); expect(replay.header.width).toBe(20); From 0aeb4ab5c30d035c0c6e09c7be31ce004dc0e766 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:23:20 +0000 Subject: [PATCH 9/9] test(js): assert settled state deduplication --- js/tests/terminal-capture.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index db13046..6d231c9 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -76,7 +76,12 @@ describe('PTY terminal capture', () => { expect(capture.transcript).toContain('ready:true:20x4'); expect(capture.transcript).toContain('typed:hello'); expect(capture.transcript).toContain('resized:32x6'); - expect(capture.frames.length).toBeLessThanOrEqual(8); + const settledStates = capture.frames.map(({ time: _time, ...frame }) => + JSON.stringify(frame) + ); + for (let index = 1; index < settledStates.length; index += 1) { + expect(settledStates[index]).not.toBe(settledStates[index - 1]); + } const replay = await readAsciicast(join(artifactDirectory, 'session.cast')); expect(replay.header.width).toBe(20);