diff --git a/codex-rs/tui/src/exec_cell/live_output.rs b/codex-rs/tui/src/exec_cell/live_output.rs new file mode 100644 index 0000000000..a4d6aaf19a --- /dev/null +++ b/codex-rs/tui/src/exec_cell/live_output.rs @@ -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, + tail: VecDeque, + 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> { + 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> { + 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; diff --git a/codex-rs/tui/src/exec_cell/live_output_tests.rs b/codex-rs/tui/src/exec_cell/live_output_tests.rs new file mode 100644 index 0000000000..246c82022f --- /dev/null +++ b/codex-rs/tui/src/exec_cell/live_output_tests.rs @@ -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::>(), + expected.iter().map(String::as_str).collect::>() + ); +} + +#[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::>(), + contiguous.lines().collect::>() + ); + } +} + +#[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::(); + + 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::() <= LIVE_COMMAND_OUTPUT_MAX_BYTES); +} diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs index 58976e12ac..40bef5d6b3 100644 --- a/codex-rs/tui/src/exec_cell/mod.rs +++ b/codex-rs/tui/src/exec_cell/mod.rs @@ -1,3 +1,4 @@ +mod live_output; mod model; mod render; diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 55f20d015a..eb852f9a80 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -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, } 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> { - 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> { - 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 } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 22d31e816c..55135de9ad 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -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( diff --git a/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap b/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap new file mode 100644 index 0000000000..eb47a610cc --- /dev/null +++ b/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap @@ -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(), + ]), + ], +)