Keep streamed command output bounded in the TUI (#34359)

## Why

Large command streams, including commands that never emit a newline, can make an active TUI cell grow without bound.

## What changed

- Retain live output in full up to 1 MiB, then preserve the first and last 50 completed lines plus the current partial line.
- Bound individual long lines while preserving UTF-8 boundaries, visible head and tail content, and ANSI rendering around omission markers.
- Show omitted line and byte counts in previews and transcripts. Final command output still replaces the bounded live preview when execution completes.

## Testing

Added unit and snapshot coverage for chunk boundaries, split CRLF sequences, newline-free and UTF-8 output, truncated ANSI sequences, retained-output limits, and preview and transcript rendering.

GitOrigin-RevId: 3cf3cc862db686f20ac09b1f6e7f9dc6d264e6eb
This commit is contained in:
Charlie Marsh
2026-07-20 14:44:25 +00:00
committed by copyberry
parent 20440a0833
commit ef6b597f41
6 changed files with 490 additions and 6 deletions
+256
View File
@@ -0,0 +1,256 @@
use itertools::Either;
use std::borrow::Cow;
use std::collections::VecDeque;
const LIVE_COMMAND_OUTPUT_MAX_BYTES: usize = 1024 * 1024;
const LIVE_COMMAND_OUTPUT_MAX_LINES: usize = 50;
const LIVE_COMMAND_OUTPUT_MAX_LINE_BYTES: usize =
LIVE_COMMAND_OUTPUT_MAX_BYTES / (2 * LIVE_COMMAND_OUTPUT_MAX_LINES + 2);
const LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES: usize = LIVE_COMMAND_OUTPUT_MAX_LINE_BYTES / 2;
const LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES: usize =
LIVE_COMMAND_OUTPUT_MAX_LINE_BYTES - LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES;
/// A bounded, incremental preview of streamed command output.
///
/// All output is retained until the byte budget is exceeded. Once truncated, the first and last
/// 50 completed lines and any in-progress line are retained, and each line independently keeps a
/// prefix and suffix so commands that never emit a newline cannot grow the active cell without bound.
#[derive(Debug, Default)]
pub(super) struct LiveCommandOutput {
full_output: String,
truncated: bool,
head: Vec<String>,
tail: VecDeque<String>,
current: LiveCommandOutputLine,
completed_lines: usize,
has_partial_line: bool,
pending_carriage_return: bool,
}
impl LiveCommandOutput {
/// Appends a delta, preserving `str::lines` semantics across arbitrary chunk boundaries.
pub(super) fn push_str(&mut self, chunk: &str) {
if !self.truncated {
if self.full_output.len().saturating_add(chunk.len()) <= LIVE_COMMAND_OUTPUT_MAX_BYTES {
self.full_output.push_str(chunk);
self.completed_lines = self
.completed_lines
.saturating_add(chunk.bytes().filter(|byte| *byte == b'\n').count());
if !chunk.is_empty() {
self.has_partial_line = !chunk.ends_with('\n');
}
return;
}
self.truncated = true;
let full_output = std::mem::take(&mut self.full_output);
self.completed_lines = 0;
self.has_partial_line = false;
self.push_truncated_str(&full_output);
}
self.push_truncated_str(chunk);
}
/// Appends bounded output while treating a split CRLF as one terminator and retaining lone CRs.
fn push_truncated_str(&mut self, chunk: &str) {
for part in chunk.split_inclusive('\n') {
let Some(part) = part.strip_suffix('\n') else {
if part.is_empty() {
continue;
}
if self.pending_carriage_return {
self.current.push_str("\r");
self.pending_carriage_return = false;
}
let part = if let Some(part) = part.strip_suffix('\r') {
self.pending_carriage_return = true;
part
} else {
part
};
self.current.push_str(part);
self.has_partial_line |= !part.is_empty() || self.pending_carriage_return;
continue;
};
let has_carriage_return = part.ends_with('\r');
let part = part.strip_suffix('\r').unwrap_or(part);
if self.pending_carriage_return && (has_carriage_return || !part.is_empty()) {
self.current.push_str("\r");
}
self.pending_carriage_return = false;
self.current.push_str(part);
self.completed_lines = self.completed_lines.saturating_add(1);
self.has_partial_line = false;
let line = std::mem::take(&mut self.current).render();
if self.head.len() < LIVE_COMMAND_OUTPUT_MAX_LINES {
self.head.push(line);
} else {
if self.tail.len() == LIVE_COMMAND_OUTPUT_MAX_LINES {
self.tail.pop_front();
}
self.tail.push_back(line);
}
}
}
pub(super) fn total_lines(&self) -> usize {
self.completed_lines
.saturating_add(usize::from(self.has_partial_line))
}
pub(super) fn retained_lines(&self) -> usize {
if self.truncated {
self.head
.len()
.saturating_add(self.tail.len())
.saturating_add(usize::from(self.has_partial_line))
} else {
self.total_lines()
}
}
/// Returns reverse-capable preview lines, abbreviating long lines even below the byte budget.
pub(super) fn lines(&self) -> impl DoubleEndedIterator<Item = Cow<'_, str>> {
if self.truncated {
Either::Left(
self.head
.iter()
.chain(self.tail.iter())
.map(|line| Cow::Borrowed(line.as_str()))
.chain(
self.has_partial_line
.then(|| Cow::Owned(self.render_partial_line())),
),
)
} else {
Either::Right(self.full_output.lines().map(|line| {
if line.len() <= LIVE_COMMAND_OUTPUT_MAX_LINE_BYTES {
Cow::Borrowed(line)
} else {
let mut truncated = LiveCommandOutputLine::default();
truncated.push_str(line);
Cow::Owned(truncated.render())
}
}))
}
}
/// Returns lossless transcript lines until truncated, then inserts the omitted-line marker.
pub(super) fn transcript_lines(&self) -> impl Iterator<Item = Cow<'_, str>> {
let omitted = self.total_lines().saturating_sub(self.retained_lines());
if self.truncated {
Either::Left(
self.head
.iter()
.map(|line| Cow::Borrowed(line.as_str()))
.chain((omitted > 0).then(|| Cow::Owned(format!("… +{omitted} lines"))))
.chain(self.tail.iter().map(|line| Cow::Borrowed(line.as_str())))
.chain(
self.has_partial_line
.then(|| Cow::Owned(self.render_partial_line())),
),
)
} else {
Either::Right(self.full_output.lines().map(Cow::Borrowed))
}
}
fn render_partial_line(&self) -> String {
let mut line = self.current.render();
if self.pending_carriage_return {
line.push('\r');
}
line
}
}
#[derive(Debug, Default)]
struct LiveCommandOutputLine {
head: String,
tail: String,
omitted_bytes: usize,
}
impl LiveCommandOutputLine {
fn push_str(&mut self, chunk: &str) {
let head_remaining = if self.tail.is_empty() && self.omitted_bytes == 0 {
LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES.saturating_sub(self.head.len())
} else {
0
};
let mut head_end = head_remaining.min(chunk.len());
while !chunk.is_char_boundary(head_end) {
head_end -= 1;
}
self.head.push_str(&chunk[..head_end]);
let chunk = &chunk[head_end..];
if chunk.is_empty() {
return;
}
if chunk.len() >= LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES {
let mut tail_start = chunk.len() - LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES;
while !chunk.is_char_boundary(tail_start) {
tail_start += 1;
}
self.omitted_bytes = self
.omitted_bytes
.saturating_add(self.tail.len())
.saturating_add(tail_start);
self.tail.clear();
self.tail.push_str(&chunk[tail_start..]);
return;
}
self.tail.push_str(chunk);
let mut tail_start = self
.tail
.len()
.saturating_sub(LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES);
while !self.tail.is_char_boundary(tail_start) {
tail_start += 1;
}
if tail_start > 0 {
self.tail.drain(..tail_start);
self.omitted_bytes = self.omitted_bytes.saturating_add(tail_start);
}
}
/// Renders the retained line, closing truncated ANSI sequences before the omission marker.
fn render(&self) -> String {
let omission_marker = (self.omitted_bytes > 0)
.then(|| format!("... {} bytes omitted ...", self.omitted_bytes));
let mut line = String::with_capacity(
self.head
.len()
.saturating_add(self.tail.len())
.saturating_add(omission_marker.as_ref().map_or(0, String::len))
.saturating_add(1),
);
line.push_str(&self.head);
if let Some(omission_marker) = omission_marker {
let terminator = self.head.rfind('\x1b').and_then(|start| {
let escape = &self.head.as_bytes()[start + 1..];
match escape.first() {
Some(b']') if !escape.contains(&b'\x07') => Some('\x07'),
Some(b'[') if !escape[1..].iter().any(u8::is_ascii_alphabetic) => Some('m'),
_ => None,
}
});
if let Some(terminator) = terminator {
line.push(terminator);
}
line.push_str("\x1b[0m");
line.push_str(&omission_marker);
}
line.push_str(&self.tail);
line
}
}
#[cfg(test)]
#[path = "live_output_tests.rs"]
mod tests;
@@ -0,0 +1,135 @@
use super::LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES;
use super::LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES;
use super::LIVE_COMMAND_OUTPUT_MAX_BYTES;
use super::LiveCommandOutput;
use codex_ansi_escape::ansi_escape_line;
use pretty_assertions::assert_eq;
#[test]
fn keeps_all_short_lines_and_chunk_boundaries_within_the_live_byte_budget() {
let mut output = LiveCommandOutput::default();
for line in 1..=500 {
output.push_str(&format!("line {line}\n"));
}
for chunk in ["hell", "o\r", "\n\nwor", "ld"] {
output.push_str(chunk);
}
let expected: Vec<_> = (1..=500)
.map(|line| format!("line {line}"))
.chain(["hello".to_string(), String::new(), "world".to_string()])
.collect();
assert_eq!(output.total_lines(), expected.len());
assert_eq!(output.retained_lines(), expected.len());
assert_eq!(
output.transcript_lines().collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn switches_to_bounded_storage_after_the_byte_budget_and_preserves_split_crlf() {
let mut output = LiveCommandOutput::default();
let line = "x".repeat(LIVE_COMMAND_OUTPUT_MAX_BYTES);
output.push_str(&line);
assert_eq!(output.transcript_lines().next().expect("full line"), line);
output.push_str("y");
let line = output.transcript_lines().next().expect("bounded line");
assert!(line.contains("bytes omitted"));
assert!(line.ends_with("xy"));
for carriage_returns in ["\r", "\r\r"] {
let body = "x".repeat(LIVE_COMMAND_OUTPUT_MAX_BYTES - carriage_returns.len());
let mut contiguous = LiveCommandOutput::default();
contiguous.push_str(&format!("{body}{carriage_returns}\n"));
let mut split = LiveCommandOutput::default();
split.push_str(&body);
split.push_str(carriage_returns);
assert!(
split
.transcript_lines()
.next()
.expect("partial line")
.ends_with('\r')
);
split.push_str("\n");
assert_eq!(split.total_lines(), 1);
assert_eq!(split.retained_lines(), 1);
assert_eq!(
split.lines().collect::<Vec<_>>(),
contiguous.lines().collect::<Vec<_>>()
);
}
}
#[test]
fn truncated_ansi_sequence_does_not_hide_the_retained_tail() {
let mut output = LiveCommandOutput::default();
let prefix = "x".repeat(LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES - 2);
let osc = format!("{prefix}\x1b]0;{}\x07visible-tail", "hidden".repeat(4_000));
output.push_str(&osc);
let line = output.lines().next().expect("partial line");
let rendered = ansi_escape_line(line.as_ref())
.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>();
assert!(rendered.contains("bytes omitted"), "{rendered}");
assert!(rendered.ends_with("visible-tail"), "{rendered}");
assert_eq!(
output.transcript_lines().next().expect("transcript line"),
osc
);
}
#[test]
fn bounds_long_no_newline_output_and_preserves_utf8_head_and_tail() {
let mut output = LiveCommandOutput::default();
let chunk = "🦀".repeat(1024);
for _ in 0..600 {
output.push_str(&chunk);
}
let line = output.lines().next().expect("partial line");
let retained_bytes = LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES + LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES
- LIVE_COMMAND_OUTPUT_LINE_HEAD_BYTES % "🦀".len()
- LIVE_COMMAND_OUTPUT_LINE_TAIL_BYTES % "🦀".len();
assert_eq!(output.total_lines(), 1);
assert_eq!(output.retained_lines(), 1);
assert!(line.starts_with("🦀🦀🦀"));
assert!(line.ends_with("🦀🦀🦀"));
assert!(line.contains(&format!(
"... {} bytes omitted ...",
600 * chunk.len() - retained_bytes
)));
assert!(line.len() < LIVE_COMMAND_OUTPUT_MAX_BYTES);
}
#[test]
fn retained_output_stays_within_the_live_byte_budget() {
let mut output = LiveCommandOutput::default();
let body = "output ".repeat(4_000);
for line in 1..=180 {
output.push_str(&format!("head-{line} {body} tail-{line}\n"));
}
output.push_str(&format!("partial-head {body} partial-tail"));
let lines: Vec<_> = output.lines().collect();
assert_eq!(output.total_lines(), 181);
assert_eq!(output.retained_lines(), 101);
assert!(lines.first().expect("head line").starts_with("head-1 "));
assert!(lines[50].starts_with("head-131 "));
assert!(lines.last().expect("tail line").ends_with(" partial-tail"));
assert_eq!(
output.transcript_lines().nth(50).expect("omission line"),
"… +80 lines"
);
assert!(lines.iter().map(|line| line.len()).sum::<usize>() <= LIVE_COMMAND_OUTPUT_MAX_BYTES);
}
+1
View File
@@ -1,3 +1,4 @@
mod live_output;
mod model;
mod render;
+25 -6
View File
@@ -9,14 +9,18 @@ use std::borrow::Cow;
use std::time::Duration;
use std::time::Instant;
use super::live_output::LiveCommandOutput;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
use itertools::Either;
#[derive(Debug, Default)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
/// The aggregated stderr + stdout interleaved.
/// The finalized, interleaved stderr and stdout that replaces any streamed preview.
aggregated_output: String,
/// The live preview while command-output deltas are still arriving.
live_output: Option<LiveCommandOutput>,
}
impl CommandOutput {
@@ -24,23 +28,35 @@ impl CommandOutput {
Self {
exit_code,
aggregated_output,
live_output: None,
}
}
/// Returns the total number of logical lines and the number retained for rendering.
pub(super) fn line_counts(&self) -> (usize, usize) {
let total = self.aggregated_output.lines().count();
(total, total)
match self.live_output.as_ref() {
Some(output) => (output.total_lines(), output.retained_lines()),
None => {
let total = self.aggregated_output.lines().count();
(total, total)
}
}
}
/// Returns retained preview lines with reverse traversal for efficient tail rendering.
pub(super) fn lines(&self) -> impl DoubleEndedIterator<Item = Cow<'_, str>> {
self.aggregated_output.lines().map(Cow::Borrowed)
match self.live_output.as_ref() {
Some(output) => Either::Left(output.lines()),
None => Either::Right(self.aggregated_output.lines().map(Cow::Borrowed)),
}
}
/// Returns lines for the expanded transcript, including any storage-level omission marker.
pub(super) fn transcript_lines(&self) -> impl Iterator<Item = Cow<'_, str>> {
self.aggregated_output.lines().map(Cow::Borrowed)
match self.live_output.as_ref() {
Some(output) => Either::Left(output.transcript_lines()),
None => Either::Right(self.aggregated_output.lines().map(Cow::Borrowed)),
}
}
}
@@ -167,7 +183,10 @@ impl ExecCell {
return false;
};
let output = call.output.get_or_insert_with(CommandOutput::default);
output.aggregated_output.push_str(chunk);
output
.live_output
.get_or_insert_with(LiveCommandOutput::default)
.push_str(chunk);
true
}
+27
View File
@@ -937,6 +937,33 @@ mod tests {
assert_eq!(render_line_text(&user_shell.lines[100]), "line 160");
}
#[test]
fn truncated_live_output_preview_and_transcript_snapshot() {
let mut cell = new_active_exec_command(
"call-id".to_string(),
vec!["bash".into(), "-lc".into(), "echo output".into()],
Vec::new(),
ExecCommandSource::Agent,
/*interaction_input*/ None,
/*animations_enabled*/ false,
);
let hidden = "\x1b[2m".repeat(300_000);
let output = format!(
"\x1b[31mhead error that wraps onto the next row\x1b[0m{hidden}\x1b[32mtail output that also wraps\x1b[0m"
);
assert!(cell.append_output("call-id", &output));
let preview = cell.display_lines(/*width*/ 60);
cell.calls[0].start_time = None;
cell.mark_failed();
let transcript = cell.transcript_lines(/*width*/ 60);
insta::assert_debug_snapshot!(
"truncated_live_output_preview_and_transcript",
(preview, transcript)
);
}
#[test]
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
let truncated = ExecCell::limit_lines_from_start(
@@ -0,0 +1,46 @@
---
source: tui/src/exec_cell/render.rs
expression: "(preview, transcript)"
---
(
[
Line::from_iter([
Span::from("•").dim(),
Span::from(" "),
Span::from("Running").bold(),
Span::from(" "),
Span::from("echo").fg(Color::Rgb(137, 180, 250)),
Span::from(" output").fg(Color::Rgb(205, 214, 244)),
]),
Line::from_iter([
Span::from(" └ ").dim(),
Span::from("head error that wraps onto the next row").red().dim(),
Span::from("... 1189804 bytes").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
Line::from_iter([
Span::from(" "),
Span::from("omitted ...").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
Span::from("tail output that also wraps").green().bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
],
[
Line::from_iter([
Span::from("$ ").magenta(),
Span::from("echo").fg(Color::Rgb(137, 180, 250)),
Span::from(" output").fg(Color::Rgb(205, 214, 244)),
]),
Line::from_iter([
Span::from("head error that wraps onto the next row").red(),
Span::from("... 1189804 bytes").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).not_bold().not_dim().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
Line::from_iter([
Span::from("omitted ...").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).not_bold().not_dim().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
Span::from("tail output that also wraps").green().bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
Line::from_iter([
Span::from("✗").red().bold(),
Span::from(" (1)"),
Span::from(" • 0ms").dim(),
]),
],
)