mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5e0a4adbe5
## Why Granular copy is particularly difficult with the current output. Part of it was solved with the introduction of the `/copy` command but when you only need to copy parts of a response, you still encounter some issues: - When you copy a paragraph, the result is a sequence of separate lines instead of one correctly joined paragraph. - When a word wraps, part of it stays on the original line and the rest appears at the start of the next line. - When you copy a long command, extra line breaks are often inserted, and command arguments can be split across multiple lines. https://github.com/user-attachments/assets/0ef85c84-9363-4aad-b43a-15fce062a443 ## Solution Now that we own the scrollback and we re-create it when we resize, we have the opportunity of toggling between the raw text and the rich text we see today. - Add TUI raw scrollback mode with `tui.raw_output_mode`, `/raw [on|off]`, and the configurable `tui.keymap.global.toggle_raw_output` action. - Render transcript cells through rich/raw-aware paths so raw mode preserves source text and lets the terminal soft-wrap selection-friendly output. - Bind raw-mode toggle to `alt-r` by default, with the keybinding path toggling silently while `/raw` continues to emit confirmation messages. ## Related Issues Likely addressed by raw mode: - #12200: clean copy for multiline and soft-wrapped output. Raw mode removes Codex-inserted wrapping/indentation and lets the terminal soft-wrap logical lines. - #9252: command suggestions gain unwanted leading spaces when copied. Raw mode renders transcript text without the rich-mode left padding/gutter. - #8258: prompt output is hard to copy because of leading indentation. Raw mode renders user/source-backed transcript text without that decorative indentation. Partially or conditionally addressed: - #2880: copy/export message as Markdown. Raw mode exposes raw Markdown for terminal selection, but this PR does not add a dedicated export/copy-message command. - #19820: mouse drag selection + copy in the TUI. Raw mode improves terminal-native selection of output/history text, but this PR does not implement in-TUI mouse selection, highlighting, auto-copy, or composer selection. - #18979: copied content is divided into two parts. This should improve cases caused by Codex-inserted wraps/padding in rendered output; if the report is about pasting into the composer/input path, that remains outside this PR. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-tui` - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo test -p codex-tui raw_output_mode_can_change_without_inserting_notice -- --nocapture` - `cargo test -p codex-tui raw_slash_command_toggles_and_accepts_on_off_args -- --nocapture` - `cargo test -p codex-tui raw_output_toggle -- --nocapture` - `git diff --check` - `cargo insta pending-snapshots`
1096 lines
38 KiB
Rust
1096 lines
38 KiB
Rust
use std::time::Instant;
|
|
|
|
use super::model::CommandOutput;
|
|
use super::model::ExecCall;
|
|
use super::model::ExecCell;
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::plain_lines;
|
|
use crate::motion::MotionMode;
|
|
use crate::motion::ReducedMotionIndicator;
|
|
use crate::motion::activity_indicator;
|
|
use crate::render::highlight::highlight_bash_to_lines;
|
|
use crate::render::line_utils::prefix_lines;
|
|
use crate::render::line_utils::push_owned_lines;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::adaptive_wrap_line;
|
|
use crate::wrapping::adaptive_wrap_lines;
|
|
use codex_ansi_escape::ansi_escape_line;
|
|
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
|
|
use codex_protocol::parse_command::ParsedCommand;
|
|
use codex_shell_command::bash::extract_bash_command;
|
|
use codex_utils_elapsed::format_duration;
|
|
use itertools::Itertools;
|
|
use ratatui::prelude::*;
|
|
use ratatui::style::Modifier;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Wrap;
|
|
use textwrap::WordSplitter;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
|
|
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
|
|
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
|
|
const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
|
|
|
|
pub(crate) struct OutputLinesParams {
|
|
pub(crate) line_limit: usize,
|
|
pub(crate) only_err: bool,
|
|
pub(crate) include_angle_pipe: bool,
|
|
pub(crate) include_prefix: bool,
|
|
}
|
|
|
|
pub(crate) fn new_active_exec_command(
|
|
call_id: String,
|
|
command: Vec<String>,
|
|
parsed: Vec<ParsedCommand>,
|
|
source: ExecCommandSource,
|
|
interaction_input: Option<String>,
|
|
animations_enabled: bool,
|
|
) -> ExecCell {
|
|
ExecCell::new(
|
|
ExecCall {
|
|
call_id,
|
|
command,
|
|
parsed,
|
|
output: None,
|
|
source,
|
|
start_time: Some(Instant::now()),
|
|
duration: None,
|
|
interaction_input,
|
|
},
|
|
animations_enabled,
|
|
)
|
|
}
|
|
|
|
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
|
|
let command_display = if let Some((_, script)) = extract_bash_command(command) {
|
|
script.to_string()
|
|
} else {
|
|
command.join(" ")
|
|
};
|
|
match input {
|
|
Some(data) if !data.is_empty() => {
|
|
let preview = summarize_interaction_input(data);
|
|
format!("Interacted with `{command_display}`, sent `{preview}`")
|
|
}
|
|
_ => format!("Waited for `{command_display}`"),
|
|
}
|
|
}
|
|
|
|
fn summarize_interaction_input(input: &str) -> String {
|
|
let single_line = input.replace('\n', "\\n");
|
|
let sanitized = single_line.replace('`', "\\`");
|
|
if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS {
|
|
return sanitized;
|
|
}
|
|
|
|
let mut preview = String::new();
|
|
for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) {
|
|
preview.push(ch);
|
|
}
|
|
preview.push_str("...");
|
|
preview
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct OutputLines {
|
|
pub(crate) lines: Vec<Line<'static>>,
|
|
pub(crate) omitted: Option<usize>,
|
|
}
|
|
|
|
pub(crate) fn output_lines(
|
|
output: Option<&CommandOutput>,
|
|
params: OutputLinesParams,
|
|
) -> OutputLines {
|
|
let OutputLinesParams {
|
|
line_limit,
|
|
only_err,
|
|
include_angle_pipe,
|
|
include_prefix,
|
|
} = params;
|
|
let CommandOutput {
|
|
aggregated_output, ..
|
|
} = match output {
|
|
Some(output) if only_err && output.exit_code == 0 => {
|
|
return OutputLines {
|
|
lines: Vec::new(),
|
|
omitted: None,
|
|
};
|
|
}
|
|
Some(output) => output,
|
|
None => {
|
|
return OutputLines {
|
|
lines: Vec::new(),
|
|
omitted: None,
|
|
};
|
|
}
|
|
};
|
|
|
|
let src = aggregated_output;
|
|
let lines: Vec<&str> = src.lines().collect();
|
|
let total = lines.len();
|
|
let mut out: Vec<Line<'static>> = Vec::new();
|
|
|
|
let head_end = total.min(line_limit);
|
|
for (i, raw) in lines[..head_end].iter().enumerate() {
|
|
let mut line = ansi_escape_line(raw);
|
|
let prefix = if !include_prefix {
|
|
""
|
|
} else if i == 0 && include_angle_pipe {
|
|
" └ "
|
|
} else {
|
|
" "
|
|
};
|
|
line.spans.insert(0, prefix.into());
|
|
line.spans.iter_mut().for_each(|span| {
|
|
span.style = span.style.add_modifier(Modifier::DIM);
|
|
});
|
|
out.push(line);
|
|
}
|
|
|
|
let show_ellipsis = total > 2 * line_limit;
|
|
let omitted = if show_ellipsis {
|
|
Some(total - 2 * line_limit)
|
|
} else {
|
|
None
|
|
};
|
|
if show_ellipsis {
|
|
let omitted = total - 2 * line_limit;
|
|
out.push(ExecCell::output_ellipsis_line(omitted));
|
|
}
|
|
|
|
let tail_start = if show_ellipsis {
|
|
total - line_limit
|
|
} else {
|
|
head_end
|
|
};
|
|
for raw in lines[tail_start..].iter() {
|
|
let mut line = ansi_escape_line(raw);
|
|
if include_prefix {
|
|
line.spans.insert(0, " ".into());
|
|
}
|
|
line.spans.iter_mut().for_each(|span| {
|
|
span.style = span.style.add_modifier(Modifier::DIM);
|
|
});
|
|
out.push(line);
|
|
}
|
|
|
|
OutputLines {
|
|
lines: out,
|
|
omitted,
|
|
}
|
|
}
|
|
|
|
fn activity_marker(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
|
|
activity_indicator(
|
|
start_time,
|
|
MotionMode::from_animations_enabled(animations_enabled),
|
|
ReducedMotionIndicator::StaticBullet,
|
|
)
|
|
.unwrap_or_else(|| "•".dim())
|
|
}
|
|
|
|
impl HistoryCell for ExecCell {
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
if self.is_exploring_cell() {
|
|
self.exploring_display_lines(width)
|
|
} else {
|
|
self.command_display_lines(width)
|
|
}
|
|
}
|
|
|
|
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let mut lines: Vec<Line<'static>> = vec![];
|
|
for (i, call) in self.iter_calls().enumerate() {
|
|
if i > 0 {
|
|
lines.push("".into());
|
|
}
|
|
let script = strip_bash_lc_and_escape(&call.command);
|
|
let highlighted_script = highlight_bash_to_lines(&script);
|
|
let cmd_display = adaptive_wrap_lines(
|
|
&highlighted_script,
|
|
RtOptions::new(width as usize)
|
|
.initial_indent("$ ".magenta().into())
|
|
.subsequent_indent(" ".into()),
|
|
);
|
|
lines.extend(cmd_display);
|
|
|
|
if let Some(output) = call.output.as_ref() {
|
|
if !call.is_unified_exec_interaction() {
|
|
let wrap_width = width.max(1) as usize;
|
|
let wrap_opts = RtOptions::new(wrap_width);
|
|
for unwrapped in output.formatted_output.lines().map(ansi_escape_line) {
|
|
let wrapped = adaptive_wrap_line(&unwrapped, wrap_opts.clone());
|
|
push_owned_lines(&wrapped, &mut lines);
|
|
}
|
|
}
|
|
let duration = call
|
|
.duration
|
|
.map(format_duration)
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let mut result: Line = if output.exit_code == 0 {
|
|
Line::from("✓".green().bold())
|
|
} else {
|
|
Line::from(vec![
|
|
"✗".red().bold(),
|
|
format!(" ({})", output.exit_code).into(),
|
|
])
|
|
};
|
|
result.push_span(format!(" • {duration}").dim());
|
|
lines.push(result);
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
|
plain_lines(self.transcript_lines(u16::MAX))
|
|
}
|
|
}
|
|
|
|
impl ExecCell {
|
|
fn output_ellipsis_text(omitted: usize) -> String {
|
|
format!("… +{omitted} lines ({TRANSCRIPT_HINT})")
|
|
}
|
|
|
|
fn output_ellipsis_line(omitted: usize) -> Line<'static> {
|
|
Line::from(vec![Self::output_ellipsis_text(omitted).dim()])
|
|
}
|
|
|
|
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let mut out: Vec<Line<'static>> = Vec::new();
|
|
out.push(Line::from(vec![
|
|
if self.is_active() {
|
|
activity_marker(self.active_start_time(), self.animations_enabled())
|
|
} else {
|
|
"•".dim()
|
|
},
|
|
" ".into(),
|
|
if self.is_active() {
|
|
"Exploring".bold()
|
|
} else {
|
|
"Explored".bold()
|
|
},
|
|
]));
|
|
|
|
let mut calls = self.calls.clone();
|
|
let mut out_indented = Vec::new();
|
|
while !calls.is_empty() {
|
|
let mut call = calls.remove(0);
|
|
if call
|
|
.parsed
|
|
.iter()
|
|
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
|
|
{
|
|
while let Some(next) = calls.first() {
|
|
if next
|
|
.parsed
|
|
.iter()
|
|
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
|
|
{
|
|
call.parsed.extend(next.parsed.clone());
|
|
calls.remove(0);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let reads_only = call
|
|
.parsed
|
|
.iter()
|
|
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }));
|
|
|
|
let call_lines: Vec<(&str, Vec<Span<'static>>)> = if reads_only {
|
|
let names = call
|
|
.parsed
|
|
.iter()
|
|
.map(|parsed| match parsed {
|
|
ParsedCommand::Read { name, .. } => name.clone(),
|
|
_ => unreachable!(),
|
|
})
|
|
.unique();
|
|
vec![(
|
|
"Read",
|
|
Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(),
|
|
)]
|
|
} else {
|
|
let mut lines = Vec::new();
|
|
for parsed in &call.parsed {
|
|
match parsed {
|
|
ParsedCommand::Read { name, .. } => {
|
|
lines.push(("Read", vec![name.clone().into()]));
|
|
}
|
|
ParsedCommand::ListFiles { cmd, path } => {
|
|
lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()]));
|
|
}
|
|
ParsedCommand::Search { cmd, query, path } => {
|
|
let spans = match (query, path) {
|
|
(Some(q), Some(p)) => {
|
|
vec![q.clone().into(), " in ".dim(), p.clone().into()]
|
|
}
|
|
(Some(q), None) => vec![q.clone().into()],
|
|
_ => vec![cmd.clone().into()],
|
|
};
|
|
lines.push(("Search", spans));
|
|
}
|
|
ParsedCommand::Unknown { cmd } => {
|
|
lines.push(("Run", vec![cmd.clone().into()]));
|
|
}
|
|
}
|
|
}
|
|
lines
|
|
};
|
|
|
|
for (title, line) in call_lines {
|
|
let line = Line::from(line);
|
|
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
|
|
let subsequent_indent = " ".repeat(initial_indent.width()).into();
|
|
let wrapped = adaptive_wrap_line(
|
|
&line,
|
|
RtOptions::new(width as usize)
|
|
.initial_indent(initial_indent)
|
|
.subsequent_indent(subsequent_indent),
|
|
);
|
|
push_owned_lines(&wrapped, &mut out_indented);
|
|
}
|
|
}
|
|
|
|
out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into()));
|
|
out
|
|
}
|
|
|
|
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let [call] = &self.calls.as_slice() else {
|
|
panic!("Expected exactly one call in a command display cell");
|
|
};
|
|
let layout = EXEC_DISPLAY_LAYOUT;
|
|
let success = call.output.as_ref().map(|o| o.exit_code == 0);
|
|
let bullet = match success {
|
|
Some(true) => "•".green().bold(),
|
|
Some(false) => "•".red().bold(),
|
|
None => activity_marker(call.start_time, self.animations_enabled()),
|
|
};
|
|
let is_interaction = call.is_unified_exec_interaction();
|
|
let title = if is_interaction {
|
|
""
|
|
} else if self.is_active() {
|
|
"Running"
|
|
} else if call.is_user_shell_command() {
|
|
"You ran"
|
|
} else {
|
|
"Ran"
|
|
};
|
|
|
|
let mut header_line = if is_interaction {
|
|
Line::from(vec![bullet.clone(), " ".into()])
|
|
} else {
|
|
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()])
|
|
};
|
|
let header_prefix_width = header_line.width();
|
|
|
|
let cmd_display = if call.is_unified_exec_interaction() {
|
|
format_unified_exec_interaction(&call.command, call.interaction_input.as_deref())
|
|
} else {
|
|
strip_bash_lc_and_escape(&call.command)
|
|
};
|
|
let highlighted_lines = highlight_bash_to_lines(&cmd_display);
|
|
|
|
let continuation_wrap_width = layout.command_continuation.wrap_width(width);
|
|
let continuation_opts =
|
|
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
|
|
|
|
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
if let Some((first, rest)) = highlighted_lines.split_first() {
|
|
let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1);
|
|
let first_opts =
|
|
RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation);
|
|
|
|
let mut first_wrapped: Vec<Line<'static>> = Vec::new();
|
|
push_owned_lines(&adaptive_wrap_line(first, first_opts), &mut first_wrapped);
|
|
let mut first_wrapped_iter = first_wrapped.into_iter();
|
|
if let Some(first_segment) = first_wrapped_iter.next() {
|
|
header_line.extend(first_segment);
|
|
}
|
|
continuation_lines.extend(first_wrapped_iter);
|
|
|
|
for line in rest {
|
|
push_owned_lines(
|
|
&adaptive_wrap_line(line, continuation_opts.clone()),
|
|
&mut continuation_lines,
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut lines: Vec<Line<'static>> = vec![header_line];
|
|
|
|
let continuation_lines = Self::limit_lines_from_start(
|
|
&continuation_lines,
|
|
layout.command_continuation_max_lines,
|
|
);
|
|
if !continuation_lines.is_empty() {
|
|
lines.extend(prefix_lines(
|
|
continuation_lines,
|
|
Span::from(layout.command_continuation.initial_prefix).dim(),
|
|
Span::from(layout.command_continuation.subsequent_prefix).dim(),
|
|
));
|
|
}
|
|
|
|
if let Some(output) = call.output.as_ref() {
|
|
let line_limit = if call.is_user_shell_command() {
|
|
USER_SHELL_TOOL_CALL_MAX_LINES
|
|
} else {
|
|
TOOL_CALL_MAX_LINES
|
|
};
|
|
let raw_output = output_lines(
|
|
Some(output),
|
|
OutputLinesParams {
|
|
line_limit,
|
|
only_err: false,
|
|
include_angle_pipe: false,
|
|
include_prefix: false,
|
|
},
|
|
);
|
|
let display_limit = if call.is_user_shell_command() {
|
|
USER_SHELL_TOOL_CALL_MAX_LINES
|
|
} else {
|
|
layout.output_max_lines
|
|
};
|
|
|
|
if raw_output.lines.is_empty() {
|
|
if !call.is_unified_exec_interaction() {
|
|
lines.extend(prefix_lines(
|
|
vec![Line::from("(no output)".dim())],
|
|
Span::from(layout.output_block.initial_prefix).dim(),
|
|
Span::from(layout.output_block.subsequent_prefix),
|
|
));
|
|
}
|
|
} else {
|
|
// Wrap first so that truncation is applied to on-screen lines
|
|
// rather than logical lines. This ensures that a small number
|
|
// of very long lines cannot flood the viewport.
|
|
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
|
|
let output_wrap_width = layout.output_block.wrap_width(width);
|
|
let output_opts =
|
|
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
|
|
for line in &raw_output.lines {
|
|
push_owned_lines(
|
|
&adaptive_wrap_line(line, output_opts.clone()),
|
|
&mut wrapped_output,
|
|
);
|
|
}
|
|
|
|
let prefixed_output = prefix_lines(
|
|
wrapped_output,
|
|
Span::from(layout.output_block.initial_prefix).dim(),
|
|
Span::from(layout.output_block.subsequent_prefix),
|
|
);
|
|
let trimmed_output = Self::truncate_lines_middle(
|
|
&prefixed_output,
|
|
display_limit,
|
|
width,
|
|
raw_output.omitted,
|
|
Some(Line::from(
|
|
Span::from(layout.output_block.subsequent_prefix).dim(),
|
|
)),
|
|
);
|
|
|
|
if !trimmed_output.is_empty() {
|
|
lines.extend(trimmed_output);
|
|
}
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
|
|
if lines.len() <= keep {
|
|
return lines.to_vec();
|
|
}
|
|
if keep == 0 {
|
|
return vec![Self::ellipsis_line(lines.len())];
|
|
}
|
|
|
|
let mut out: Vec<Line<'static>> = lines[..keep].to_vec();
|
|
out.push(Self::ellipsis_line(lines.len() - keep));
|
|
out
|
|
}
|
|
|
|
/// Truncates a list of lines to fit within `max_rows` viewport rows,
|
|
/// keeping a head portion and a tail portion with an ellipsis line
|
|
/// in between.
|
|
///
|
|
/// `max_rows` is measured in viewport rows (the actual space a line
|
|
/// occupies after `Paragraph::wrap`), not logical lines. Each line's
|
|
/// row cost is computed via `Paragraph::line_count` at the given
|
|
/// `width`. This ensures that a single logical line containing a
|
|
/// long URL (which wraps to several viewport rows) is properly
|
|
/// accounted for.
|
|
///
|
|
/// The ellipsis message reports the number of omitted *lines*
|
|
/// (logical, not rows) to keep the count stable across terminal
|
|
/// widths. `omitted_hint` carries forward any previously reported
|
|
/// omitted count (from upstream truncation); `ellipsis_prefix`
|
|
/// prepends the output gutter prefix to the ellipsis line.
|
|
fn truncate_lines_middle(
|
|
lines: &[Line<'static>],
|
|
max_rows: usize,
|
|
width: u16,
|
|
omitted_hint: Option<usize>,
|
|
ellipsis_prefix: Option<Line<'static>>,
|
|
) -> Vec<Line<'static>> {
|
|
let width = width.max(1);
|
|
if max_rows == 0 {
|
|
return Vec::new();
|
|
}
|
|
let line_rows: Vec<usize> = lines
|
|
.iter()
|
|
.map(|line| {
|
|
let is_whitespace_only = line
|
|
.spans
|
|
.iter()
|
|
.all(|span| span.content.chars().all(char::is_whitespace));
|
|
if is_whitespace_only {
|
|
line.width().div_ceil(usize::from(width)).max(1)
|
|
} else {
|
|
Paragraph::new(Text::from(vec![line.clone()]))
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width)
|
|
.max(1)
|
|
}
|
|
})
|
|
.collect();
|
|
let total_rows: usize = line_rows.iter().sum();
|
|
if total_rows <= max_rows {
|
|
return lines.to_vec();
|
|
}
|
|
// Reserve space for the transcript hint itself so the returned output
|
|
// still respects the row budget on narrow terminals.
|
|
let estimated_omitted = omitted_hint.unwrap_or(0)
|
|
+ lines
|
|
.len()
|
|
.saturating_sub(usize::from(omitted_hint.is_some()));
|
|
let ellipsis_rows =
|
|
Self::output_ellipsis_row_count(estimated_omitted, width, ellipsis_prefix.as_ref());
|
|
if ellipsis_rows >= max_rows {
|
|
return vec![Self::output_ellipsis_line_with_prefix(
|
|
estimated_omitted,
|
|
ellipsis_prefix.as_ref(),
|
|
)];
|
|
}
|
|
|
|
let available_rows = max_rows - ellipsis_rows;
|
|
let head_budget = available_rows / 2;
|
|
let tail_budget = available_rows - head_budget;
|
|
let mut head_lines: Vec<Line<'static>> = Vec::new();
|
|
let mut head_rows = 0usize;
|
|
let mut head_end = 0usize;
|
|
while head_end < lines.len() {
|
|
let line_row_count = line_rows[head_end];
|
|
if head_rows + line_row_count > head_budget {
|
|
break;
|
|
}
|
|
head_rows += line_row_count;
|
|
head_lines.push(lines[head_end].clone());
|
|
head_end += 1;
|
|
}
|
|
|
|
let mut tail_lines_reversed: Vec<Line<'static>> = Vec::new();
|
|
let mut tail_rows = 0usize;
|
|
let mut tail_start = lines.len();
|
|
while tail_start > head_end {
|
|
let idx = tail_start - 1;
|
|
let line_row_count = line_rows[idx];
|
|
if tail_rows + line_row_count > tail_budget {
|
|
break;
|
|
}
|
|
tail_rows += line_row_count;
|
|
tail_lines_reversed.push(lines[idx].clone());
|
|
tail_start -= 1;
|
|
}
|
|
|
|
let mut out = head_lines;
|
|
let base = omitted_hint.unwrap_or(0);
|
|
let additional = lines
|
|
.len()
|
|
.saturating_sub(out.len() + tail_lines_reversed.len())
|
|
.saturating_sub(usize::from(omitted_hint.is_some()));
|
|
out.push(Self::output_ellipsis_line_with_prefix(
|
|
base + additional,
|
|
ellipsis_prefix.as_ref(),
|
|
));
|
|
|
|
out.extend(tail_lines_reversed.into_iter().rev());
|
|
|
|
out
|
|
}
|
|
|
|
fn ellipsis_line(omitted: usize) -> Line<'static> {
|
|
Line::from(vec![format!("… +{omitted} lines").dim()])
|
|
}
|
|
|
|
fn output_ellipsis_row_count(
|
|
omitted: usize,
|
|
width: u16,
|
|
prefix: Option<&Line<'static>>,
|
|
) -> usize {
|
|
Paragraph::new(Text::from(vec![Self::output_ellipsis_line_with_prefix(
|
|
omitted, prefix,
|
|
)]))
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width)
|
|
.max(1)
|
|
}
|
|
|
|
/// Builds an output ellipsis line (`… +N lines (ctrl + t to view transcript)`)
|
|
/// with an optional leading prefix so the ellipsis aligns with the output gutter.
|
|
fn output_ellipsis_line_with_prefix(
|
|
omitted: usize,
|
|
prefix: Option<&Line<'static>>,
|
|
) -> Line<'static> {
|
|
let mut line = prefix.cloned().unwrap_or_default();
|
|
line.push_span(Self::output_ellipsis_text(omitted).dim());
|
|
line
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct PrefixedBlock {
|
|
initial_prefix: &'static str,
|
|
subsequent_prefix: &'static str,
|
|
}
|
|
|
|
impl PrefixedBlock {
|
|
const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self {
|
|
Self {
|
|
initial_prefix,
|
|
subsequent_prefix,
|
|
}
|
|
}
|
|
|
|
fn wrap_width(self, total_width: u16) -> usize {
|
|
let prefix_width = UnicodeWidthStr::width(self.initial_prefix)
|
|
.max(UnicodeWidthStr::width(self.subsequent_prefix));
|
|
usize::from(total_width).saturating_sub(prefix_width).max(1)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ExecDisplayLayout {
|
|
command_continuation: PrefixedBlock,
|
|
command_continuation_max_lines: usize,
|
|
output_block: PrefixedBlock,
|
|
output_max_lines: usize,
|
|
}
|
|
|
|
impl ExecDisplayLayout {
|
|
const fn new(
|
|
command_continuation: PrefixedBlock,
|
|
command_continuation_max_lines: usize,
|
|
output_block: PrefixedBlock,
|
|
output_max_lines: usize,
|
|
) -> Self {
|
|
Self {
|
|
command_continuation,
|
|
command_continuation_max_lines,
|
|
output_block,
|
|
output_max_lines,
|
|
}
|
|
}
|
|
}
|
|
|
|
const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
|
|
PrefixedBlock::new(" │ ", " │ "),
|
|
/*command_continuation_max_lines*/ 2,
|
|
PrefixedBlock::new(" └ ", " "),
|
|
/*output_max_lines*/ 5,
|
|
);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn render_line_text(line: &Line<'static>) -> String {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
}
|
|
|
|
#[test]
|
|
fn user_shell_output_is_limited_by_screen_lines() {
|
|
let long_url_like = format!(
|
|
"https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/{}",
|
|
"very-long-segment-".repeat(120),
|
|
);
|
|
let aggregated_output = format!("{long_url_like}\n{long_url_like}\n");
|
|
|
|
// Baseline: how many screen lines would we get if we simply wrapped
|
|
// all logical lines without any truncation?
|
|
let output = CommandOutput {
|
|
exit_code: 0,
|
|
aggregated_output,
|
|
formatted_output: String::new(),
|
|
};
|
|
let width = 20;
|
|
let layout = EXEC_DISPLAY_LAYOUT;
|
|
let raw_output = output_lines(
|
|
Some(&output),
|
|
OutputLinesParams {
|
|
// Large enough to include all logical lines without
|
|
// triggering the ellipsis in `output_lines`.
|
|
line_limit: 100,
|
|
only_err: false,
|
|
include_angle_pipe: false,
|
|
include_prefix: false,
|
|
},
|
|
);
|
|
let output_wrap_width = layout.output_block.wrap_width(width);
|
|
let output_opts =
|
|
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
|
|
let mut full_wrapped_output: Vec<Line<'static>> = Vec::new();
|
|
for line in &raw_output.lines {
|
|
push_owned_lines(
|
|
&adaptive_wrap_line(line, output_opts.clone()),
|
|
&mut full_wrapped_output,
|
|
);
|
|
}
|
|
let full_prefixed_output = prefix_lines(
|
|
full_wrapped_output,
|
|
Span::from(layout.output_block.initial_prefix).dim(),
|
|
Span::from(layout.output_block.subsequent_prefix),
|
|
);
|
|
let full_screen_lines = Paragraph::new(Text::from(full_prefixed_output))
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width);
|
|
|
|
// Sanity check: this scenario should produce more screen lines than
|
|
// the user shell per-call limit when no truncation is applied. If
|
|
// this ever fails, the test no longer exercises the regression.
|
|
assert!(
|
|
full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES,
|
|
"expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}",
|
|
);
|
|
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo long".into()],
|
|
parsed: Vec::new(),
|
|
output: Some(output),
|
|
source: ExecCommandSource::UserShell,
|
|
start_time: None,
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
|
|
// Use a narrow width so each logical line wraps into many on-screen lines.
|
|
let lines = cell.command_display_lines(width);
|
|
let rendered_rows = Paragraph::new(Text::from(lines.clone()))
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width);
|
|
let header_rows = Paragraph::new(Text::from(vec![lines[0].clone()]))
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width);
|
|
let output_screen_rows = rendered_rows.saturating_sub(header_rows);
|
|
|
|
let contains_ellipsis = lines
|
|
.iter()
|
|
.any(|line| line.spans.iter().any(|span| span.content.contains("… +")));
|
|
|
|
// Regression guard: previously this scenario could render hundreds of
|
|
// wrapped rows because truncation happened before final viewport
|
|
// wrapping. The row-aware truncation now caps visible output rows.
|
|
assert!(
|
|
output_screen_rows <= USER_SHELL_TOOL_CALL_MAX_LINES,
|
|
"expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} output rows, got {output_screen_rows} (total rows: {rendered_rows})",
|
|
);
|
|
assert!(
|
|
contains_ellipsis,
|
|
"expected truncated output to include an ellipsis line"
|
|
);
|
|
let normalized = lines
|
|
.iter()
|
|
.map(render_line_text)
|
|
.join(" ")
|
|
.split_whitespace()
|
|
.join(" ");
|
|
assert!(
|
|
normalized.contains(TRANSCRIPT_HINT),
|
|
"expected truncated output to advertise transcript shortcut, got {normalized}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_lines_middle_keeps_omitted_count_in_line_units() {
|
|
let lines = vec![
|
|
Line::from(" └ short"),
|
|
Line::from(" this-is-a-very-long-token-that-wraps-many-rows"),
|
|
Line::from(format!(
|
|
" {}",
|
|
ExecCell::output_ellipsis_text(/*omitted*/ 4)
|
|
)),
|
|
Line::from(" tail"),
|
|
];
|
|
|
|
let truncated = ExecCell::truncate_lines_middle(
|
|
&lines,
|
|
/*max_rows*/ 2,
|
|
/*width*/ 80,
|
|
Some(4),
|
|
Some(Line::from(" ".dim())),
|
|
);
|
|
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
|
|
|
|
assert!(
|
|
rendered
|
|
.iter()
|
|
.any(|line| line.contains("… +6 lines (ctrl + t to view transcript)")),
|
|
"expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_lines_ellipsis_includes_transcript_hint() {
|
|
let output = CommandOutput {
|
|
exit_code: 0,
|
|
aggregated_output: (1..=7).map(|n| n.to_string()).join("\n"),
|
|
formatted_output: String::new(),
|
|
};
|
|
|
|
let rendered: Vec<String> = output_lines(
|
|
Some(&output),
|
|
OutputLinesParams {
|
|
line_limit: 2,
|
|
only_err: false,
|
|
include_angle_pipe: false,
|
|
include_prefix: false,
|
|
},
|
|
)
|
|
.lines
|
|
.iter()
|
|
.map(render_line_text)
|
|
.collect();
|
|
|
|
assert!(
|
|
rendered
|
|
.iter()
|
|
.any(|line| line.contains("… +3 lines (ctrl + t to view transcript)")),
|
|
"expected logical truncation to include transcript hint, got: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
|
|
let truncated = ExecCell::limit_lines_from_start(
|
|
&[
|
|
Line::from("first"),
|
|
Line::from("second"),
|
|
Line::from("third"),
|
|
],
|
|
/*keep*/ 2,
|
|
);
|
|
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
vec![
|
|
"first".to_string(),
|
|
"second".to_string(),
|
|
"… +1 lines".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_lines_middle_does_not_truncate_blank_prefixed_output_lines() {
|
|
let mut lines = vec![Line::from(" └ start")];
|
|
lines.extend(std::iter::repeat_n(Line::from(" "), 26));
|
|
lines.push(Line::from(" end"));
|
|
|
|
let truncated = ExecCell::truncate_lines_middle(
|
|
&lines, /*max_rows*/ 28, /*width*/ 80, /*omitted_hint*/ None,
|
|
/*ellipsis_prefix*/ None,
|
|
);
|
|
|
|
assert_eq!(truncated, lines);
|
|
}
|
|
|
|
#[test]
|
|
fn command_display_does_not_split_long_url_token() {
|
|
let url = "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text";
|
|
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), format!("echo {url}")],
|
|
parsed: Vec::new(),
|
|
output: None,
|
|
source: ExecCommandSource::UserShell,
|
|
start_time: None,
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
let rendered: Vec<String> = cell
|
|
.command_display_lines(/*width*/ 36)
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
rendered.iter().filter(|line| line.contains(url)).count(),
|
|
1,
|
|
"expected full URL in one rendered line, got: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_command_without_animations_is_stable() {
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
|
|
parsed: Vec::new(),
|
|
output: None,
|
|
source: ExecCommandSource::Agent,
|
|
start_time: Some(Instant::now()),
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
let first: Vec<String> = cell
|
|
.command_display_lines(/*width*/ 80)
|
|
.iter()
|
|
.map(render_line_text)
|
|
.collect();
|
|
let second: Vec<String> = cell
|
|
.command_display_lines(/*width*/ 80)
|
|
.iter()
|
|
.map(render_line_text)
|
|
.collect();
|
|
|
|
assert_eq!(first, second);
|
|
assert_eq!(first, vec!["• Running echo done".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn exploring_display_does_not_split_long_url_like_search_query() {
|
|
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path";
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), "rg foo".into()],
|
|
parsed: vec![ParsedCommand::Search {
|
|
cmd: format!("rg {url_like}"),
|
|
query: Some(url_like.to_string()),
|
|
path: None,
|
|
}],
|
|
output: None,
|
|
source: ExecCommandSource::Agent,
|
|
start_time: None,
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
let rendered: Vec<String> = cell
|
|
.display_lines(/*width*/ 36)
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
rendered
|
|
.iter()
|
|
.filter(|line| line.contains(url_like))
|
|
.count(),
|
|
1,
|
|
"expected full URL-like query in one rendered line, got: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_display_does_not_split_long_url_like_token_without_scheme() {
|
|
let url = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789jkl012mno345pqr678";
|
|
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
|
|
parsed: Vec::new(),
|
|
output: Some(CommandOutput {
|
|
exit_code: 0,
|
|
formatted_output: String::new(),
|
|
aggregated_output: url.to_string(),
|
|
}),
|
|
source: ExecCommandSource::UserShell,
|
|
start_time: None,
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
let rendered: Vec<String> = cell
|
|
.command_display_lines(/*width*/ 36)
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
rendered.iter().filter(|line| line.contains(url)).count(),
|
|
1,
|
|
"expected full URL-like token in one rendered line, got: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desired_transcript_height_accounts_for_wrapped_url_like_rows() {
|
|
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes";
|
|
let call = ExecCall {
|
|
call_id: "call-id".to_string(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
|
|
parsed: Vec::new(),
|
|
output: Some(CommandOutput {
|
|
exit_code: 0,
|
|
formatted_output: url.to_string(),
|
|
aggregated_output: url.to_string(),
|
|
}),
|
|
source: ExecCommandSource::Agent,
|
|
start_time: None,
|
|
duration: None,
|
|
interaction_input: None,
|
|
};
|
|
|
|
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
|
let width: u16 = 36;
|
|
let logical_height = cell.transcript_lines(width).len() as u16;
|
|
let wrapped_height = cell.desired_transcript_height(width);
|
|
|
|
assert!(
|
|
wrapped_height > logical_height,
|
|
"expected transcript height to account for wrapped URL-like rows, logical_height={logical_height}, wrapped_height={wrapped_height}"
|
|
);
|
|
}
|
|
}
|