diff --git a/js/.changeset/tender-terminals-repaint.md b/js/.changeset/tender-terminals-repaint.md new file mode 100644 index 0000000..75385b3 --- /dev/null +++ b/js/.changeset/tender-terminals-repaint.md @@ -0,0 +1,6 @@ +--- +'command-stream': patch +--- + +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 3b531a2..d34418f 100644 --- a/js/src/terminal-capture.mjs +++ b/js/src/terminal-capture.mjs @@ -17,21 +17,77 @@ 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]); } return segments; }; +const splitPendingRenderSequence = (data, flush) => { + if (!data || flush) { + return [data, '']; + } + const maximum = Math.min(data.length, ERASE_SCREEN.length - 1); + for (let length = maximum; length > 0; length -= 1) { + if (ERASE_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(ERASE_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] === '') { @@ -234,13 +290,10 @@ 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 interactionScheduled = false; let captureError; - const appendFrame = () => { const frame = terminalFrame(terminal, elapsed); if (!frames.at(-1) || !sameFrame(frames.at(-1), frame)) { @@ -251,20 +304,7 @@ export const captureTerminal = async ({ clearTimeout(settleTimer); settleTimer = setTimeout(appendFrame, settleMilliseconds); }; - const queueOutput = (data) => { - const segments = splitRenderSegments(data); - for (const [index, segment] of segments.entries()) { - writeQueue = writeQueue.then( - () => - new Promise((written) => { - terminal.write(segment, written); - }) - ); - if (index < segments.length - 1) { - writeQueue = writeQueue.then(appendFrame); - } - } - }; + const outputWriter = createTerminalOutputWriter(terminal, appendFrame); const advanceInteractions = () => { if ( interactionScheduled || @@ -276,7 +316,7 @@ export const captureTerminal = async ({ interactionScheduled = true; traceCapture('interaction-scheduled', { interactionIndex }); - writeQueue.then(() => { + outputWriter.after(() => { appendFrame(); traceCapture('interaction-applied', { interactionIndex }); applyInteraction({ @@ -304,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 @@ -329,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/js/tests/fixtures/tui-leading-repaint-fixture.mjs b/js/tests/fixtures/tui-leading-repaint-fixture.mjs new file mode 100644 index 0000000..5c655a6 --- /dev/null +++ b/js/tests/fixtures/tui-leading-repaint-fixture.mjs @@ -0,0 +1,10 @@ +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('\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 d1ad083..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).toBeLessThan(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); @@ -113,6 +118,18 @@ describe('PTY terminal capture', () => { } }); + 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')], + 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( 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..5867236 --- /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 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 a60d386..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]; @@ -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(ERASE_SCREEN.len() - 1); + let pending_length = (1..=maximum) + .rev() + .find(|length| ERASE_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,8 @@ 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; let mut dirty = false; @@ -267,10 +278,16 @@ 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 && render_data.starts_with(ERASE_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); } @@ -338,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"), diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs index da62a96..e0e8caa 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_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[2'; \ + sleep 0.02; printf 'J\\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(