diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9f7f0518..fba3caf1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,7 +44,9 @@ use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; use crate::history_cell::CommandOutput; +use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; // streaming internals are provided by crate::streaming and crate::markdown_stream @@ -68,7 +70,7 @@ pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane<'a>, - active_exec_cell: Option, + active_exec_cell: Option, config: Config, initial_user_message: Option, total_token_usage: TokenUsage, @@ -123,7 +125,7 @@ impl ChatWidget<'_> { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); - self.add_to_history(HistoryCell::new_session_info(&self.config, event, true)); + self.add_to_history(&history_cell::new_session_info(&self.config, event, true)); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -195,14 +197,14 @@ impl ChatWidget<'_> { } fn on_error(&mut self, message: String) { - self.add_to_history(HistoryCell::new_error_event(message)); + self.add_to_history(&history_cell::new_error_event(message)); self.bottom_pane.set_task_running(false); self.stream.clear_all(); self.mark_needs_redraw(); } fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) { - self.add_to_history(HistoryCell::new_plan_update(update)); + self.add_to_history(&history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -237,7 +239,7 @@ impl ChatWidget<'_> { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(HistoryCell::new_patch_event( + self.add_to_history(&history_cell::new_patch_event( PatchEventType::ApplyBegin { auto_approved: event.auto_approved, }, @@ -372,7 +374,7 @@ impl ChatWidget<'_> { self.active_exec_cell = None; let pending = std::mem::take(&mut self.pending_exec_completions); for (command, parsed, output) in pending { - self.add_to_history(HistoryCell::new_completed_exec_command( + self.add_to_history(&history_cell::new_completed_exec_command( command, parsed, output, )); } @@ -384,9 +386,9 @@ impl ChatWidget<'_> { event: codex_core::protocol::PatchApplyEndEvent, ) { if event.success { - self.add_to_history(HistoryCell::new_patch_apply_success(event.stdout)); + self.add_to_history(&history_cell::new_patch_apply_success(event.stdout)); } else { - self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr)); + self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr)); } } @@ -402,7 +404,7 @@ impl ChatWidget<'_> { .map(|r| format!("\n{r}")) .unwrap_or_default() ); - self.add_to_history(HistoryCell::new_background_event(text)); + self.add_to_history(&history_cell::new_background_event(text)); let request = ApprovalRequest::Exec { id, @@ -419,7 +421,7 @@ impl ChatWidget<'_> { ev: ApplyPatchApprovalRequestEvent, ) { self.flush_answer_stream_with_separator(); - self.add_to_history(HistoryCell::new_patch_event( + self.add_to_history(&history_cell::new_patch_event( PatchEventType::ApprovalRequest, ev.changes.clone(), )); @@ -446,11 +448,11 @@ impl ChatWidget<'_> { ); // Accumulate parsed commands into a single active Exec cell so they stack match self.active_exec_cell.as_mut() { - Some(HistoryCell::Exec(exec)) => { + Some(exec) => { exec.parsed.extend(ev.parsed_cmd); } _ => { - self.active_exec_cell = Some(HistoryCell::new_active_exec_command( + self.active_exec_cell = Some(history_cell::new_active_exec_command( ev.command, ev.parsed_cmd, )); @@ -463,11 +465,11 @@ impl ChatWidget<'_> { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation)); + self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation)); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(HistoryCell::new_completed_mcp_tool_call( + self.add_to_history(&*history_cell::new_completed_mcp_tool_call( 80, ev.invocation, ev.duration, @@ -564,14 +566,14 @@ impl ChatWidget<'_> { fn flush_active_exec_cell(&mut self) { if let Some(active) = self.active_exec_cell.take() { self.app_event_tx - .send(AppEvent::InsertHistory(active.plain_lines())); + .send(AppEvent::InsertHistory(active.display_lines())); } } - fn add_to_history(&mut self, cell: HistoryCell) { + fn add_to_history(&mut self, cell: &dyn HistoryCell) { self.flush_active_exec_cell(); self.app_event_tx - .send(AppEvent::InsertHistory(cell.plain_lines())); + .send(AppEvent::InsertHistory(cell.display_lines())); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -607,7 +609,7 @@ impl ChatWidget<'_> { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(HistoryCell::new_user_prompt(text.clone())); + self.add_to_history(&history_cell::new_user_prompt(text.clone())); } } @@ -680,18 +682,18 @@ impl ChatWidget<'_> { } pub(crate) fn add_diff_output(&mut self, diff_output: String) { - self.add_to_history(HistoryCell::new_diff_output(diff_output.clone())); + self.add_to_history(&history_cell::new_diff_output(diff_output.clone())); } pub(crate) fn add_status_output(&mut self) { - self.add_to_history(HistoryCell::new_status_output( + self.add_to_history(&history_cell::new_status_output( &self.config, &self.total_token_usage, )); } pub(crate) fn add_prompts_output(&mut self) { - self.add_to_history(HistoryCell::new_prompts_output()); + self.add_to_history(&history_cell::new_prompts_output()); } /// Forward file-search results to the bottom pane. diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 2433b344..5a3199c0 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -361,19 +361,22 @@ fn style_del() -> Style { #[cfg(test)] mod tests { use super::*; - use crate::history_cell::HistoryCell; - use crate::text_block::TextBlock; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::text::Text; + use ratatui::widgets::Paragraph; + use ratatui::widgets::WidgetRef; + use ratatui::widgets::Wrap; fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); - let cell = HistoryCell::PendingPatch { - view: TextBlock::new(lines), - }; terminal - .draw(|f| f.render_widget_ref(&cell, f.area())) + .draw(|f| { + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .render_ref(f.area(), f.buffer_mut()) + }) .expect("draw"); assert_snapshot!(name, terminal.backend()); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8295a897..f420065d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3,7 +3,6 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::slash_command::SlashCommand; -use crate::text_block::TextBlock; use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; use codex_ansi_escape::ansi_escape_line; @@ -50,18 +49,28 @@ pub(crate) enum PatchEventType { ApplyBegin { auto_approved: bool }, } -fn span_to_static(span: &Span) -> Span<'static> { - Span { - style: span.style, - content: std::borrow::Cow::Owned(span.content.clone().into_owned()), +/// Represents an event to display in the conversation history. Returns its +/// `Vec>` representation to make it easier to display in a +/// scrollable list. +pub(crate) trait HistoryCell { + fn display_lines(&self) -> Vec>; + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.display_lines())) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) } } -fn line_to_static(line: &Line) -> Line<'static> { - Line { - style: line.style, - alignment: line.alignment, - spans: line.spans.iter().map(span_to_static).collect(), +pub(crate) struct PlainHistoryCell { + lines: Vec>, +} + +impl HistoryCell for PlainHistoryCell { + fn display_lines(&self) -> Vec> { + self.lines.clone() } } @@ -70,92 +79,30 @@ pub(crate) struct ExecCell { pub(crate) parsed: Vec, pub(crate) output: Option, } +impl HistoryCell for ExecCell { + fn display_lines(&self) -> Vec> { + exec_command_lines(&self.command, &self.parsed, self.output.as_ref()) + } +} -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. -pub(crate) enum HistoryCell { - /// Welcome message. - WelcomeMessage { - view: TextBlock, - }, +impl WidgetRef for &ExecCell { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(Text::from(self.display_lines())) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} - /// Message from the user. - UserPrompt { - view: TextBlock, - }, - - Exec(ExecCell), - - /// An MCP tool call that has not finished yet. - ActiveMcpToolCall { - view: TextBlock, - }, - - /// Completed MCP tool call where we show the result serialized as JSON. - CompletedMcpToolCall { - view: TextBlock, - }, - - /// Completed MCP tool call where the result is an image. - /// Admittedly, [mcp_types::CallToolResult] can have multiple content types, - /// which could be a mix of text and images, so we need to tighten this up. - // NOTE: For image output we keep the *original* image around and lazily - // compute a resized copy that fits the available cell width. Caching the - // resized version avoids doing the potentially expensive rescale twice - // because the scroll-view first calls `height()` for layouting and then - // `render_window()` for painting. - CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, - }, - - /// Background event. - BackgroundEvent { - view: TextBlock, - }, - - /// Output from the `/diff` command. - GitDiffOutput { - view: TextBlock, - }, - - /// Output from the `/status` command. - StatusOutput { - view: TextBlock, - }, - - /// Output from the `/prompts` command. - PromptsOutput { - view: TextBlock, - }, - - /// Error event from the backend. - ErrorEvent { - view: TextBlock, - }, - - /// Info describing the newly-initialized session. - SessionInfo { - view: TextBlock, - }, - - /// A pending code patch that is awaiting user approval. Mirrors the - /// behaviour of `ExecCell` so the user sees *what* patch the - /// model wants to apply before being prompted to approve or deny it. - PendingPatch { - view: TextBlock, - }, - - /// A human‑friendly rendering of the model's current plan and step - /// statuses provided via the `update_plan` tool. - PlanUpdate { - view: TextBlock, - }, - - /// Result of applying a patch (success or failure) with optional output. - PatchApplyResult { - view: TextBlock, - }, +struct CompletedMcpToolCallWithImageOutput { + _image: DynamicImage, +} +impl HistoryCell for CompletedMcpToolCallWithImageOutput { + fn display_lines(&self) -> Vec> { + vec![ + Line::from("tool result (image output omitted)"), + Line::from(""), + ] + } } const TOOL_CALL_MAX_LINES: usize = 5; @@ -181,744 +128,652 @@ fn pretty_provider_name(id: &str) -> String { } } -impl HistoryCell { - /// Return a cloned, plain representation of the cell's lines suitable for - /// one‑shot insertion into the terminal scrollback. Image cells are - /// represented with a simple placeholder. - /// These lines are also rendered directly by ratatui wrapped in a Paragraph. - pub(crate) fn plain_lines(&self) -> Vec> { - match self { - HistoryCell::WelcomeMessage { view } - | HistoryCell::UserPrompt { view } - | HistoryCell::BackgroundEvent { view } - | HistoryCell::GitDiffOutput { view } - | HistoryCell::StatusOutput { view } - | HistoryCell::PromptsOutput { view } - | HistoryCell::ErrorEvent { view } - | HistoryCell::SessionInfo { view } - | HistoryCell::CompletedMcpToolCall { view } - | HistoryCell::PendingPatch { view } - | HistoryCell::PlanUpdate { view } - | HistoryCell::PatchApplyResult { view } - | HistoryCell::ActiveMcpToolCall { view, .. } => { - view.lines.iter().map(line_to_static).collect() - } - HistoryCell::Exec(ExecCell { - command, - parsed, - output, - }) => HistoryCell::exec_command_lines(command, parsed, output.as_ref()), - HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![ - Line::from("tool result (image output omitted)"), - Line::from(""), - ], - } - } +pub(crate) fn new_background_event(message: String) -> PlainHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("event".dim())); + lines.extend(message.lines().map(|line| ansi_escape_line(line).dim())); + lines.push(Line::from("")); + PlainHistoryCell { lines } +} - pub(crate) fn new_background_event(message: String) -> Self { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("event".dim())); - lines.extend(message.lines().map(|line| ansi_escape_line(line).dim())); - lines.push(Line::from("")); - HistoryCell::BackgroundEvent { - view: TextBlock::new(lines), - } - } - - pub(crate) fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.plain_lines())) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - pub(crate) fn new_session_info( - config: &Config, - event: SessionConfiguredEvent, - is_first_event: bool, - ) -> Self { - let SessionConfiguredEvent { - model, - session_id: _, - history_log_id: _, - history_entry_count: _, - } = event; - if is_first_event { - let cwd_str = match relativize_to_home(&config.cwd) { - Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()), - Some(_) => "~".to_string(), - None => config.cwd.display().to_string(), - }; - - let lines: Vec> = vec![ - Line::from(vec![ - Span::raw(">_ ").dim(), - Span::styled( - "You are using OpenAI Codex in", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" {cwd_str}")).dim(), - ]), - Line::from("".dim()), - Line::from(" To get started, describe a task or try one of these commands:".dim()), - Line::from("".dim()), - Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()), - Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()), - Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()), - Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()), - Line::from("".dim()), - ]; - HistoryCell::WelcomeMessage { - view: TextBlock::new(lines), - } - } else if config.model == model { - HistoryCell::SessionInfo { - view: TextBlock::new(Vec::new()), - } - } else { - let lines = vec![ - Line::from("model changed:".magenta().bold()), - Line::from(format!("requested: {}", config.model)), - Line::from(format!("used: {model}")), - Line::from(""), - ]; - HistoryCell::SessionInfo { - view: TextBlock::new(lines), - } - } - } - - pub(crate) fn new_user_prompt(message: String) -> Self { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("user".cyan().bold())); - lines.extend(message.lines().map(|l| Line::from(l.to_string()))); - lines.push(Line::from("")); - - HistoryCell::UserPrompt { - view: TextBlock::new(lines), - } - } - - pub(crate) fn new_active_exec_command( - command: Vec, - parsed: Vec, - ) -> Self { - HistoryCell::new_exec_cell(command, parsed, None) - } - - pub(crate) fn new_completed_exec_command( - command: Vec, - parsed: Vec, - output: CommandOutput, - ) -> Self { - HistoryCell::new_exec_cell(command, parsed, Some(output)) - } - - fn new_exec_cell( - command: Vec, - parsed: Vec, - output: Option, - ) -> Self { - HistoryCell::Exec(ExecCell { - command, - parsed, - output, - }) - } - - fn exec_command_lines( - command: &[String], - parsed: &[ParsedCommand], - output: Option<&CommandOutput>, - ) -> Vec> { - match parsed.is_empty() { - true => HistoryCell::new_exec_command_generic(command, output), - false => HistoryCell::new_parsed_command(parsed, output), - } - } - - fn new_parsed_command( - parsed_commands: &[ParsedCommand], - output: Option<&CommandOutput>, - ) -> Vec> { - let mut lines: Vec = vec![match output { - None => Line::from("⚙︎ Working".magenta().bold()), - Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()), - Some(o) => Line::from(format!("✗ Failed (exit {})", o.exit_code).red().bold()), - }]; - - for (i, parsed) in parsed_commands.iter().enumerate() { - let text = match parsed { - ParsedCommand::Read { name, .. } => format!("📖 {name}"), - ParsedCommand::ListFiles { cmd, path } => match path { - Some(p) => format!("📂 {p}"), - None => format!("📂 {}", shlex_join_safe(cmd)), - }, - ParsedCommand::Search { query, path, cmd } => match (query, path) { - (Some(q), Some(p)) => format!("🔎 {q} in {p}"), - (Some(q), None) => format!("🔎 {q}"), - (None, Some(p)) => format!("🔎 {p}"), - (None, None) => format!("🔎 {}", shlex_join_safe(cmd)), - }, - ParsedCommand::Format { .. } => "✨ Formatting".to_string(), - ParsedCommand::Test { cmd } => format!("🧪 {}", shlex_join_safe(cmd)), - ParsedCommand::Lint { cmd, .. } => format!("🧹 {}", shlex_join_safe(cmd)), - ParsedCommand::Unknown { cmd } => format!("⌨️ {}", shlex_join_safe(cmd)), - }; - - let first_prefix = if i == 0 { " └ " } else { " " }; - for (j, line_text) in text.lines().enumerate() { - let prefix = if j == 0 { first_prefix } else { " " }; - lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)), - Span::styled(line_text.to_string(), Style::default().fg(LIGHT_BLUE)), - ])); - } - } - - lines.extend(output_lines(output, true, false)); - lines.push(Line::from("")); - - lines - } - - fn new_exec_command_generic( - command: &[String], - output: Option<&CommandOutput>, - ) -> Vec> { - let mut lines: Vec> = Vec::new(); - let command_escaped = strip_bash_lc_and_escape(command); - let mut cmd_lines = command_escaped.lines(); - if let Some(first) = cmd_lines.next() { - lines.push(Line::from(vec![ - "⚡ Running ".to_string().magenta(), - first.to_string().into(), - ])); - } else { - lines.push(Line::from("⚡ Running".to_string().magenta())); - } - for cont in cmd_lines { - lines.push(Line::from(cont.to_string())); - } - - lines.extend(output_lines(output, false, true)); - - lines - } - - pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self { - let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); - let lines: Vec = vec![ - title_line, - format_mcp_invocation(invocation.clone()), - Line::from(""), - ]; - - HistoryCell::ActiveMcpToolCall { - view: TextBlock::new(lines), - } - } - - /// If the first content is an image, return a new cell with the image. - /// TODO(rgwood-dd): Handle images properly even if they're not the first result. - fn try_new_completed_mcp_tool_call_with_image_output( - result: &Result, - ) -> Option { - match result { - Ok(mcp_types::CallToolResult { content, .. }) => { - if let Some(mcp_types::ContentBlock::ImageContent(image)) = content.first() { - let raw_data = - match base64::engine::general_purpose::STANDARD.decode(&image.data) { - Ok(data) => data, - Err(e) => { - error!("Failed to decode image data: {e}"); - return None; - } - }; - let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() - { - Ok(reader) => reader, - Err(e) => { - error!("Failed to guess image format: {e}"); - return None; - } - }; - - let image = match reader.decode() { - Ok(image) => image, - Err(e) => { - error!("Image decoding failed: {e}"); - return None; - } - }; - - Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image }) - } else { - None - } - } - _ => None, - } - } - - pub(crate) fn new_completed_mcp_tool_call( - num_cols: usize, - invocation: McpInvocation, - duration: Duration, - success: bool, - result: Result, - ) -> Self { - if let Some(cell) = Self::try_new_completed_mcp_tool_call_with_image_output(&result) { - return cell; - } - - let duration = format_duration(duration); - let status_str = if success { "success" } else { "failed" }; - let title_line = Line::from(vec![ - "tool".magenta(), - " ".into(), - if success { - status_str.green() - } else { - status_str.red() - }, - format!(", duration: {duration}").dim(), - ]); - - let mut lines: Vec> = Vec::new(); - lines.push(title_line); - lines.push(format_mcp_invocation(invocation)); - - match result { - Ok(mcp_types::CallToolResult { content, .. }) => { - if !content.is_empty() { - lines.push(Line::from("")); - - for tool_call_result in content { - let line_text = match tool_call_result { - mcp_types::ContentBlock::TextContent(text) => { - format_and_truncate_tool_result( - &text.text, - TOOL_CALL_MAX_LINES, - num_cols, - ) - } - mcp_types::ContentBlock::ImageContent(_) => { - // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall` - "".to_string() - } - mcp_types::ContentBlock::AudioContent(_) => { - "