mirror of
https://github.com/openai/codex.git
synced 2026-05-05 22:01:37 +03:00
Remove the legacy TUI split (#15922)
This is the part 1 of 2 PRs that will delete the `tui` / `tui_app_server` split. This part simply deletes the existing `tui` directory and marks the `tui_app_server` feature flag as removed. I left the `tui_app_server` feature flag in place for now so its presence doesn't result in an error. It is simply ignored. Part 2 will rename the `tui_app_server` directory `tui`. I did this as two parts to reduce visible code churn.
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
mod model;
|
||||
mod render;
|
||||
|
||||
pub(crate) use model::CommandOutput;
|
||||
#[cfg(test)]
|
||||
pub(crate) use model::ExecCall;
|
||||
pub(crate) use model::ExecCell;
|
||||
pub(crate) use render::OutputLinesParams;
|
||||
pub(crate) use render::TOOL_CALL_MAX_LINES;
|
||||
pub(crate) use render::new_active_exec_command;
|
||||
pub(crate) use render::output_lines;
|
||||
pub(crate) use render::spinner;
|
||||
@@ -1,176 +0,0 @@
|
||||
//! Data model for grouped exec-call history cells in the TUI transcript.
|
||||
//!
|
||||
//! An `ExecCell` can represent either a single command or an "exploring" group of related read/
|
||||
//! list/search commands. The chat widget relies on stable `call_id` matching to route progress and
|
||||
//! end events into the right cell, and it treats "call id not found" as a real signal (for
|
||||
//! example, an orphan end that should render as a separate history entry).
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
/// The aggregated stderr + stdout interleaved.
|
||||
pub(crate) aggregated_output: String,
|
||||
/// The formatted output of the command, as seen by the model.
|
||||
pub(crate) formatted_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ExecCall {
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) command: Vec<String>,
|
||||
pub(crate) parsed: Vec<ParsedCommand>,
|
||||
pub(crate) output: Option<CommandOutput>,
|
||||
pub(crate) source: ExecCommandSource,
|
||||
pub(crate) start_time: Option<Instant>,
|
||||
pub(crate) duration: Option<Duration>,
|
||||
pub(crate) interaction_input: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecCell {
|
||||
pub(crate) calls: Vec<ExecCall>,
|
||||
animations_enabled: bool,
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
|
||||
Self {
|
||||
calls: vec![call],
|
||||
animations_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_added_call(
|
||||
&self,
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
parsed: Vec<ParsedCommand>,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
) -> Option<Self> {
|
||||
let call = ExecCall {
|
||||
call_id,
|
||||
command,
|
||||
parsed,
|
||||
output: None,
|
||||
source,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input,
|
||||
};
|
||||
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
|
||||
Some(Self {
|
||||
calls: [self.calls.clone(), vec![call]].concat(),
|
||||
animations_enabled: self.animations_enabled,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the most recently matching call as finished and returns whether a call was found.
|
||||
///
|
||||
/// Callers should treat `false` as a routing mismatch rather than silently ignoring it. The
|
||||
/// chat widget uses that signal to avoid attaching an orphan `exec_end` event to an unrelated
|
||||
/// active exploring cell, which would incorrectly collapse two transcript entries together.
|
||||
pub(crate) fn complete_call(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
output: CommandOutput,
|
||||
duration: Duration,
|
||||
) -> bool {
|
||||
let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {
|
||||
return false;
|
||||
};
|
||||
call.output = Some(output);
|
||||
call.duration = Some(duration);
|
||||
call.start_time = None;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn should_flush(&self) -> bool {
|
||||
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn mark_failed(&mut self) {
|
||||
for call in self.calls.iter_mut() {
|
||||
if call.output.is_none() {
|
||||
let elapsed = call
|
||||
.start_time
|
||||
.map(|st| st.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
call.start_time = None;
|
||||
call.duration = Some(elapsed);
|
||||
call.output = Some(CommandOutput {
|
||||
exit_code: 1,
|
||||
formatted_output: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_exploring_cell(&self) -> bool {
|
||||
self.calls.iter().all(Self::is_exploring_call)
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.calls.iter().any(|c| c.output.is_none())
|
||||
}
|
||||
|
||||
pub(crate) fn active_start_time(&self) -> Option<Instant> {
|
||||
self.calls
|
||||
.iter()
|
||||
.find(|c| c.output.is_none())
|
||||
.and_then(|c| c.start_time)
|
||||
}
|
||||
|
||||
pub(crate) fn animations_enabled(&self) -> bool {
|
||||
self.animations_enabled
|
||||
}
|
||||
|
||||
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
|
||||
self.calls.iter()
|
||||
}
|
||||
|
||||
pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool {
|
||||
if chunk.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {
|
||||
return false;
|
||||
};
|
||||
let output = call.output.get_or_insert_with(CommandOutput::default);
|
||||
output.aggregated_output.push_str(chunk);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
|
||||
!matches!(call.source, ExecCommandSource::UserShell)
|
||||
&& !call.parsed.is_empty()
|
||||
&& call.parsed.iter().all(|p| {
|
||||
matches!(
|
||||
p,
|
||||
ParsedCommand::Read { .. }
|
||||
| ParsedCommand::ListFiles { .. }
|
||||
| ParsedCommand::Search { .. }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCall {
|
||||
pub(crate) fn is_user_shell_command(&self) -> bool {
|
||||
matches!(self.source, ExecCommandSource::UserShell)
|
||||
}
|
||||
|
||||
pub(crate) fn is_unified_exec_interaction(&self) -> bool {
|
||||
matches!(self.source, ExecCommandSource::UnifiedExecInteraction)
|
||||
}
|
||||
}
|
||||
@@ -1,968 +0,0 @@
|
||||
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::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
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_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
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;
|
||||
|
||||
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(format!("… +{omitted} lines").into());
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
|
||||
if !animations_enabled {
|
||||
return "•".dim();
|
||||
}
|
||||
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
|
||||
if supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
.map(|level| level.has_16m)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
shimmer_spans("•")[0].clone()
|
||||
} else {
|
||||
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
|
||||
if blink_on { "•".into() } 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
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
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() {
|
||||
spinner(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 => spinner(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();
|
||||
}
|
||||
if max_rows == 1 {
|
||||
// Carry forward any previously omitted count and add any
|
||||
// additionally hidden content lines from this truncation.
|
||||
let base = omitted_hint.unwrap_or(0);
|
||||
// When an existing ellipsis is present, `lines` already includes
|
||||
// that single representation line; exclude it from the count of
|
||||
// additionally omitted content lines.
|
||||
let extra = lines
|
||||
.len()
|
||||
.saturating_sub(usize::from(omitted_hint.is_some()));
|
||||
let omitted = base + extra;
|
||||
return vec![Self::ellipsis_line_with_prefix(
|
||||
omitted,
|
||||
ellipsis_prefix.as_ref(),
|
||||
)];
|
||||
}
|
||||
|
||||
let head_budget = (max_rows - 1) / 2;
|
||||
let tail_budget = max_rows - head_budget - 1;
|
||||
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::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()])
|
||||
}
|
||||
|
||||
/// Builds an ellipsis line (`… +N lines`) with an optional leading
|
||||
/// prefix so the ellipsis aligns with the output gutter.
|
||||
fn ellipsis_line_with_prefix(omitted: usize, prefix: Option<&Line<'static>>) -> Line<'static> {
|
||||
let mut line = prefix.cloned().unwrap_or_default();
|
||||
line.push_span(format!("… +{omitted} lines").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_protocol::protocol::ExecCommandSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[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, 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(" … +4 lines"),
|
||||
Line::from(" tail"),
|
||||
];
|
||||
|
||||
let truncated =
|
||||
ExecCell::truncate_lines_middle(&lines, 2, 12, Some(4), Some(Line::from(" ".dim())));
|
||||
let rendered: Vec<String> = truncated
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
rendered.iter().any(|line| line.contains("… +6 lines")),
|
||||
"expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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, 28, 80, None, 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, false);
|
||||
let rendered: Vec<String> = cell
|
||||
.command_display_lines(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 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, false);
|
||||
let rendered: Vec<String> = cell
|
||||
.display_lines(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, false);
|
||||
let rendered: Vec<String> = cell
|
||||
.command_display_lines(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, 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user