mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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,3 +1,4 @@
|
||||
mod live_output;
|
||||
mod model;
|
||||
mod render;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+46
@@ -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(),
|
||||
]),
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user