diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4678dd24..a373338f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -70,11 +70,12 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::ExecCell; +use crate::exec_cell::new_active_exec_command; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::AgentMessageCell; -use crate::history_cell::CommandOutput; -use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PatchEventType; @@ -673,7 +674,7 @@ impl ChatWidget { .unwrap_or(true); if needs_new { self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_exec_command( + self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), command, parsed, @@ -777,7 +778,7 @@ impl ChatWidget { } else { self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_exec_command( + self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index caabd816..bca67dfe 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -17,11 +17,15 @@ expression: visible_after through crates for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox, tui, login, ollama, and mcp. -• Ran - └ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy - file-search linux-sandbox login mcp-client mcp-server mcp-types ollama - tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; - done +• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy + │ file-search linux-sandbox login mcp-client mcp-server mcp-types ollama + │ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; + │ … +1 lines + └ --- ansi-escape/Cargo.toml + [package] + … +7 lines + ] } + tracing = { version • Explored └ Read Cargo.toml diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs new file mode 100644 index 00000000..90609111 --- /dev/null +++ b/codex-rs/tui/src/exec_cell/mod.rs @@ -0,0 +1,12 @@ +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; diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs new file mode 100644 index 00000000..2893a005 --- /dev/null +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -0,0 +1,123 @@ +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::parse_command::ParsedCommand; + +#[derive(Clone, Debug)] +pub(crate) struct CommandOutput { + pub(crate) exit_code: i32, + pub(crate) stdout: String, + pub(crate) stderr: String, + pub(crate) formatted_output: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExecCall { + pub(crate) call_id: String, + pub(crate) command: Vec, + pub(crate) parsed: Vec, + pub(crate) output: Option, + pub(crate) start_time: Option, + pub(crate) duration: Option, +} + +#[derive(Debug)] +pub(crate) struct ExecCell { + pub(crate) calls: Vec, +} + +impl ExecCell { + pub(crate) fn new(call: ExecCall) -> Self { + Self { calls: vec![call] } + } + + pub(crate) fn with_added_call( + &self, + call_id: String, + command: Vec, + parsed: Vec, + ) -> Option { + let call = ExecCall { + call_id, + command, + parsed, + output: None, + start_time: Some(Instant::now()), + duration: None, + }; + if self.is_exploring_cell() && Self::is_exploring_call(&call) { + Some(Self { + calls: [self.calls.clone(), vec![call]].concat(), + }) + } else { + None + } + } + + pub(crate) fn complete_call( + &mut self, + call_id: &str, + output: CommandOutput, + duration: Duration, + ) { + if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { + call.output = Some(output); + call.duration = Some(duration); + call.start_time = None; + } + } + + 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, + stdout: String::new(), + stderr: String::new(), + formatted_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 { + self.calls + .iter() + .find(|c| c.output.is_none()) + .and_then(|c| c.start_time) + } + + pub(crate) fn iter_calls(&self) -> impl Iterator { + self.calls.iter() + } + + pub(super) fn is_exploring_call(call: &ExecCall) -> bool { + !call.parsed.is_empty() + && call.parsed.iter().all(|p| { + matches!( + p, + ParsedCommand::Read { .. } + | ParsedCommand::ListFiles { .. } + | ParsedCommand::Search { .. } + ) + }) + } +} diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs new file mode 100644 index 00000000..a096dbe5 --- /dev/null +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -0,0 +1,492 @@ +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::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use codex_ansi_escape::ansi_escape_line; +use codex_common::elapsed::format_duration; +use codex_protocol::parse_command::ParsedCommand; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use textwrap::WordSplitter; +use unicode_width::UnicodeWidthStr; + +pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; + +pub(crate) struct OutputLinesParams { + 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, + parsed: Vec, +) -> ExecCell { + ExecCell::new(ExecCall { + call_id, + command, + parsed, + output: None, + start_time: Some(Instant::now()), + duration: None, + }) +} + +pub(crate) fn output_lines( + output: Option<&CommandOutput>, + params: OutputLinesParams, +) -> Vec> { + let OutputLinesParams { + only_err, + include_angle_pipe, + include_prefix, + } = params; + let CommandOutput { + exit_code, + stdout, + stderr, + .. + } = match output { + Some(output) if only_err && output.exit_code == 0 => return vec![], + Some(output) => output, + None => return vec![], + }; + + let src = if *exit_code == 0 { stdout } else { stderr }; + let lines: Vec<&str> = src.lines().collect(); + let total = lines.len(); + let limit = TOOL_CALL_MAX_LINES; + + let mut out = Vec::new(); + + let head_end = total.min(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 * limit; + if show_ellipsis { + let omitted = total - 2 * limit; + out.push(format!("… +{omitted} lines").into()); + } + + let tail_start = if show_ellipsis { + total - 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); + } + + out +} + +pub(crate) fn spinner(start_time: Option) -> Span<'static> { + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let idx = start_time + .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) + .unwrap_or(0); + let ch = FRAMES[idx]; + ch.to_string().into() +} + +impl HistoryCell for ExecCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.is_exploring_cell() { + self.exploring_display_lines(width) + } else { + self.command_display_lines(width) + } + } + + fn transcript_lines(&self) -> Vec> { + let mut lines: Vec> = vec![]; + for call in self.iter_calls() { + let cmd_display = strip_bash_lc_and_escape(&call.command); + for (i, part) in cmd_display.lines().enumerate() { + if i == 0 { + lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); + } else { + lines.push(vec![" ".into(), part.to_string().into()].into()); + } + } + + if let Some(output) = call.output.as_ref() { + lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + 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.push("".into()); + } + lines + } +} + +impl WidgetRef for &ExecCell { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 { + return; + } + let content_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height, + }; + let lines = self.display_lines(area.width); + let max_rows = area.height as usize; + let rendered = if lines.len() > max_rows { + lines[lines.len() - max_rows..].to_vec() + } else { + lines + }; + + Paragraph::new(Text::from(rendered)) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } +} + +impl ExecCell { + fn exploring_display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + out.push(Line::from(vec![ + if self.is_active() { + spinner(self.active_start_time()) + } else { + "•".bold() + }, + " ".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>)> = 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 = word_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> { + 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), + }; + let title = if self.is_active() { "Running" } else { "Ran" }; + + let mut header_line = + Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]); + let header_prefix_width = header_line.width(); + + let cmd_display = 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> = 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> = Vec::new(); + push_owned_lines(&word_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( + &word_wrap_line(line, continuation_opts.clone()), + &mut continuation_lines, + ); + } + } + + let mut lines: Vec> = 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 raw_output_lines = output_lines( + Some(output), + OutputLinesParams { + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let trimmed_output = + Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines); + + let mut wrapped_output: Vec> = 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 trimmed_output { + push_owned_lines( + &word_wrap_line(&line, output_opts.clone()), + &mut wrapped_output, + ); + } + + if !wrapped_output.is_empty() { + lines.extend(prefix_lines( + wrapped_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + )); + } + } + + lines + } + + fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { + if lines.len() <= keep { + return lines.to_vec(); + } + if keep == 0 { + return vec![Self::ellipsis_line(lines.len())]; + } + + let mut out: Vec> = lines[..keep].to_vec(); + out.push(Self::ellipsis_line(lines.len() - keep)); + out + } + + fn truncate_lines_middle(lines: &[Line<'static>], max: usize) -> Vec> { + if max == 0 { + return Vec::new(); + } + if lines.len() <= max { + return lines.to_vec(); + } + if max == 1 { + return vec![Self::ellipsis_line(lines.len())]; + } + + let head = (max - 1) / 2; + let tail = max - head - 1; + let mut out: Vec> = Vec::new(); + + if head > 0 { + out.extend(lines[..head].iter().cloned()); + } + + let omitted = lines.len().saturating_sub(head + tail); + out.push(Self::ellipsis_line(omitted)); + + if tail > 0 { + out.extend(lines[lines.len() - tail..].iter().cloned()); + } + + out + } + + fn ellipsis_line(omitted: usize) -> Line<'static> { + Line::from(vec![format!("… +{omitted} lines").dim()]) + } +} + +#[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(" │ ", " │ "), + 2, + PrefixedBlock::new(" └ ", " "), + 5, +); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3ae0d177..d95663d9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,10 +1,14 @@ use crate::diff_render::create_diff_summary; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::OutputLinesParams; +use crate::exec_cell::TOOL_CALL_MAX_LINES; +use crate::exec_cell::output_lines; +use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; pub(crate) use crate::status::RateLimitSnapshotDisplay; pub(crate) use crate::status::new_status_output; pub(crate) use crate::status::rate_limit_snapshot_display; @@ -14,8 +18,6 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; -use codex_ansi_escape::ansi_escape_line; -use codex_common::elapsed::format_duration; use codex_core::config::Config; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::plan_tool::PlanItemArg; @@ -25,10 +27,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; -use itertools::Itertools; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; @@ -49,14 +49,6 @@ use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; -#[derive(Clone, Debug)] -pub(crate) struct CommandOutput { - pub(crate) exit_code: i32, - pub(crate) stdout: String, - pub(crate) stderr: String, - pub(crate) formatted_output: String, -} - #[derive(Clone, Debug)] pub(crate) enum PatchEventType { ApprovalRequest, @@ -266,357 +258,6 @@ impl HistoryCell for PatchHistoryCell { } } -#[derive(Debug, Clone)] -pub(crate) struct ExecCall { - pub(crate) call_id: String, - pub(crate) command: Vec, - pub(crate) parsed: Vec, - pub(crate) output: Option, - start_time: Option, - duration: Option, -} - -#[derive(Debug)] -pub(crate) struct ExecCell { - calls: Vec, -} -impl HistoryCell for ExecCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.is_exploring_cell() { - self.exploring_display_lines(width) - } else { - self.command_display_lines(width) - } - } - - fn transcript_lines(&self) -> Vec> { - let mut lines: Vec> = vec![]; - for call in &self.calls { - let cmd_display = strip_bash_lc_and_escape(&call.command); - for (i, part) in cmd_display.lines().enumerate() { - if i == 0 { - lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); - } else { - lines.push(vec![" ".into(), part.to_string().into()].into()); - } - } - - if let Some(output) = call.output.as_ref() { - lines.extend(output.formatted_output.lines().map(ansi_escape_line)); - 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.push("".into()); - } - lines - } -} - -impl ExecCell { - fn is_active(&self) -> bool { - self.calls.iter().any(|c| c.output.is_none()) - } - - fn exploring_display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let active_start_time = self - .calls - .iter() - .find(|c| c.output.is_none()) - .and_then(|c| c.start_time); - out.push(Line::from(vec![ - if self.is_active() { - // Show an animated spinner while exploring - spinner(active_start_time) - } else { - "•".bold() - }, - " ".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(|c| matches!(c, ParsedCommand::Read { .. })) - { - while let Some(next) = calls.first() { - if next - .parsed - .iter() - .all(|c| matches!(c, ParsedCommand::Read { .. })) - { - call.parsed.extend(next.parsed.clone()); - calls.remove(0); - } else { - break; - } - } - } - let call_lines: Vec<(&str, Vec>)> = if call - .parsed - .iter() - .all(|c| matches!(c, ParsedCommand::Read { .. })) - { - let names = call - .parsed - .iter() - .map(|c| match c { - ParsedCommand::Read { name, .. } => name.clone(), - _ => unreachable!(), - }) - .unique(); - vec![( - "Read", - itertools::Itertools::intersperse( - names.into_iter().map(Into::into), - ", ".dim(), - ) - .collect(), - )] - } else { - let mut lines = Vec::new(); - for p in call.parsed { - match p { - ParsedCommand::Read { name, .. } => { - lines.push(("Read", vec![name.into()])); - } - ParsedCommand::ListFiles { cmd, path } => { - lines.push(("List", vec![path.unwrap_or(cmd).into()])); - } - ParsedCommand::Search { cmd, query, path } => { - lines.push(( - "Search", - match (query, path) { - (Some(q), Some(p)) => { - vec![q.into(), " in ".dim(), p.into()] - } - (Some(q), None) => vec![q.into()], - _ => vec![cmd.into()], - }, - )); - } - ParsedCommand::Unknown { cmd } => { - lines.push(("Run", vec![cmd.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 = word_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> { - use textwrap::Options as TwOptions; - - let mut lines: Vec> = Vec::new(); - let [call] = &self.calls.as_slice() else { - panic!("Expected exactly one call in a command display cell"); - }; - 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), - }; - let title = if self.is_active() { "Running" } else { "Ran" }; - let cmd_display = strip_bash_lc_and_escape(&call.command); - - // If the command fits on the same line as the header at the current width, - // show a single compact line: "• Ran ". Use the width of - // "• Running " (including trailing space) as the reserved prefix width. - // If the command contains newlines, always use the multi-line variant. - let reserved = "• Running ".width(); - - let mut body_lines: Vec> = Vec::new(); - - let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display); - - if highlighted_lines.len() == 1 - && highlighted_lines[0].width() < (width as usize).saturating_sub(reserved) - { - let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]); - line.extend(highlighted_lines[0].clone()); - lines.push(line); - } else { - lines.push(vec![bullet, " ".into(), title.bold()].into()); - - for hl_line in highlighted_lines.iter() { - let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4)) - .initial_indent("".into()) - .subsequent_indent(" ".into()) - // Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags. - .word_splitter(textwrap::WordSplitter::NoHyphenation); - let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts); - body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l))); - } - } - if let Some(output) = call.output.as_ref() - && output.exit_code != 0 - { - let out = output_lines( - Some(output), - OutputLinesParams { - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ) - .into_iter() - .join("\n"); - if !out.trim().is_empty() { - // Wrap the output. - for line in out.lines() { - let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4)); - body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim()))); - } - } - } - lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into())); - lines - } -} - -impl WidgetRef for &ExecCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height, - }; - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - // Keep the last `max_rows` lines in original order - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Paragraph::new(Text::from(rendered)) - .wrap(Wrap { trim: false }) - .render(content_area, buf); - } -} - -impl ExecCell { - 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, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }); - } - } - } - - pub(crate) fn new(call: ExecCall) -> Self { - ExecCell { calls: vec![call] } - } - - fn is_exploring_call(call: &ExecCall) -> bool { - !call.parsed.is_empty() - && call.parsed.iter().all(|p| { - matches!( - p, - ParsedCommand::Read { .. } - | ParsedCommand::ListFiles { .. } - | ParsedCommand::Search { .. } - ) - }) - } - - fn is_exploring_cell(&self) -> bool { - self.calls.iter().all(Self::is_exploring_call) - } - - pub(crate) fn with_added_call( - &self, - call_id: String, - command: Vec, - parsed: Vec, - ) -> Option { - let call = ExecCall { - call_id, - command, - parsed, - output: None, - start_time: Some(Instant::now()), - duration: None, - }; - if self.is_exploring_cell() && Self::is_exploring_call(&call) { - Some(Self { - calls: [self.calls.clone(), vec![call]].concat(), - }) - } else { - None - } - } - - pub(crate) fn complete_call( - &mut self, - call_id: &str, - output: CommandOutput, - duration: Duration, - ) { - if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { - call.output = Some(output); - call.duration = Some(duration); - call.start_time = None; - } - } - - pub(crate) fn should_flush(&self) -> bool { - !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) - } -} - #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, @@ -627,7 +268,6 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } } -const TOOL_CALL_MAX_LINES: usize = 5; pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { @@ -783,21 +423,6 @@ pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHist PlainHistoryCell { lines } } -pub(crate) fn new_active_exec_command( - call_id: String, - command: Vec, - parsed: Vec, -) -> ExecCell { - ExecCell::new(ExecCall { - call_id, - command, - parsed, - output: None, - start_time: Some(Instant::now()), - duration: None, - }) -} - #[derive(Debug)] struct SessionHeaderHistoryCell { version: &'static str, @@ -1116,15 +741,6 @@ impl WidgetRef for &McpToolCallCell { } } -fn spinner(start_time: Option) -> Span<'static> { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - ch.to_string().into() -} - pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, @@ -1444,79 +1060,6 @@ pub(crate) fn new_reasoning_summary_block( Box::new(new_reasoning_block(full_reasoning_buffer, config)) } -struct OutputLinesParams { - only_err: bool, - include_angle_pipe: bool, - include_prefix: bool, -} - -fn output_lines(output: Option<&CommandOutput>, params: OutputLinesParams) -> Vec> { - let OutputLinesParams { - only_err, - include_angle_pipe, - include_prefix, - } = params; - let CommandOutput { - exit_code, - stdout, - stderr, - .. - } = match output { - Some(output) if only_err && output.exit_code == 0 => return vec![], - Some(output) => output, - None => return vec![], - }; - - let src = if *exit_code == 0 { stdout } else { stderr }; - let lines: Vec<&str> = src.lines().collect(); - let total = lines.len(); - let limit = TOOL_CALL_MAX_LINES; - - let mut out = Vec::new(); - - let head_end = total.min(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); - } - - // If we will ellipsize less than the limit, just show it. - let show_ellipsis = total > 2 * limit; - if show_ellipsis { - let omitted = total - 2 * limit; - out.push(format!("… +{omitted} lines").into()); - } - - let tail_start = if show_ellipsis { - total - 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); - } - - out -} - fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments @@ -1541,9 +1084,13 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { #[cfg(test)] mod tests { use super::*; + use crate::exec_cell::CommandOutput; + use crate::exec_cell::ExecCall; + use crate::exec_cell::ExecCell; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; + use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8f51bc9d..998ecb74 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -41,6 +41,7 @@ mod cli; mod clipboard_paste; pub mod custom_terminal; mod diff_render; +mod exec_cell; mod exec_command; mod file_search; mod frames; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b92939a1..ec9652ce 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -556,7 +556,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use crate::history_cell::CommandOutput; + use crate::exec_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::history_cell::new_patch_event; @@ -687,7 +687,7 @@ mod tests { ])); cells.push(apply_end_cell); - let mut exec_cell = crate::history_cell::new_active_exec_command( + let mut exec_cell = crate::exec_cell::new_active_exec_command( "exec-1".into(), vec!["bash".into(), "-lc".into(), "ls".into()], vec![ParsedCommand::Unknown { cmd: "ls".into() }], diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap index 0964b64a..05500c62 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap @@ -1,10 +1,8 @@ --- source: tui/src/history_cell.rs -assertion_line: 1942 expression: rendered --- -• Ran - └ first_token_is_long_enou - gh_to_wrap - second_token_is_also_lon - g_enough_to_wrap +• Ran first_token_is_long_en + │ ough_to_wrap + │ second_token_is_also_lon + │ … +1 lines diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap index 7dc9d572..33644d49 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap @@ -2,6 +2,5 @@ source: tui/src/history_cell.rs expression: rendered --- -• Ran - └ echo one - echo two +• Ran echo one + │ echo two diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap index de36cd92..acd5b64b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap @@ -1,10 +1,7 @@ --- source: tui/src/history_cell.rs -assertion_line: 1797 expression: rendered --- -• Ran - └ set -o pipefail - cargo test - --all-features - --quiet +• Ran set -o pipefail + │ cargo test + │ --all-features --quiet diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap index 4a0ec938..0c7b5ee8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap @@ -2,13 +2,11 @@ source: tui/src/history_cell.rs expression: rendered --- -• Ran - └ echo - this_is_a_very_long_ - single_token_that_wi - ll_wrap_across_the_a - vailable_width - error: first line on +• Ran echo + │ this_is_a_very_long_si + │ ngle_token_that_will_w + │ … +2 lines + └ error: first line on stderr error: second line on stderr diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap index 5ca13307..27623ae1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap @@ -1,9 +1,7 @@ --- source: tui/src/history_cell.rs -assertion_line: 1869 expression: rendered --- -• Ran - └ a_very_long_token_wi - thout_spaces_to_ - force_wrapping +• Ran a_very_long_token_ + │ without_spaces_to_ + │ force_wrapping diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap index ccba8c22..ae7ee151 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap @@ -5,11 +5,6 @@ expression: rendered • Ran seq 1 10 1>&2 && false └ 1 2 - 3 - 4 - 5 - 6 - 7 - 8 + … +6 lines 9 10