Skip to content
Merged
6 changes: 6 additions & 0 deletions js/.changeset/tender-terminals-repaint.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 67 additions & 26 deletions js/src/terminal-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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] === '') {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 ||
Expand All @@ -276,7 +316,7 @@ export const captureTerminal = async ({

interactionScheduled = true;
traceCapture('interaction-scheduled', { interactionIndex });
writeQueue.then(() => {
outputWriter.after(() => {
appendFrame();
traceCapture('interaction-applied', { interactionIndex });
applyInteraction({
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions js/tests/fixtures/tui-leading-repaint-fixture.mjs
Original file line number Diff line number Diff line change
@@ -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);
19 changes: 18 additions & 1 deletion js/tests/terminal-capture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions rust/changelog.d/20260724_230700_leading_repaint.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 22 additions & 4 deletions rust/src/terminal/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,9 +61,9 @@ fn append_frame(frames: &mut Vec<TerminalFrame>, 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::<Vec<_>>();
if positions.is_empty() {
return vec![data];
Expand All @@ -80,6 +80,15 @@ fn render_segments(data: &[u8]) -> Vec<&[u8]> {
segments
}

fn drain_complete_render_data(pending: &mut Vec<u8>) -> Vec<u8> {
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<String>) {
asciicast.events.push(AsciicastEvent {
time: elapsed(started),
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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"),
Expand Down
13 changes: 13 additions & 0 deletions rust/tests/terminal_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down