From 4c76b16f77ebe820bad882e2b86a4dfbd674a9f1 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 4 Feb 2026 05:09:01 -0600 Subject: [PATCH 1/2] feat: add rich tool visualization with ExecCell component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive tool execution visualization for codi-rs TUI: ## New Components - **ExecCell**: Rich visual display for tool calls - Color-coded status (yellow=running, green=success, red=error) - Animated spinner during execution (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) - Live output streaming (last 5 lines displayed) - Duration tracking with ms/s precision - Expandable view for full input/output JSON - Input parameter preview with truncation - **ExecCellManager**: Track multiple concurrent tool executions - Add/remove/get cells by ID - Running count and cell filtering - Spinner animation tick for all running cells - Automatic cleanup of old completed cells ## Integration - Updated agent callbacks to include tool_id for tracking - Modified App to create and manage exec cells on tool events - Integrated exec cells into TUI layout between messages and input - Added spinner animation tick to event loop ## Testing - 6 insta snapshot tests for visual regression - 44 unit tests covering all ExecCell functionality: - ToolStatus states and transitions - Cell lifecycle (pending → running → success/error) - Output buffering and streaming - Duration formatting - Manager operations - Edge cases (empty results, multiline, complex JSON) ## Files Added - src/tui/components/exec_cell.rs (417 lines) - src/tui/components/mod.rs - tests/tui_exec_cell.rs (159 lines) - tests/exec_cell_unit.rs (365 lines) - tests/snapshots/*.snap (9 snapshot files) ## Files Modified - Cargo.toml (added insta dev dependency) - src/agent/types.rs (updated callback signatures) - src/agent/mod.rs (pass tool_id in callbacks) - src/orchestrate/child_agent.rs (fix callback signatures) - src/tui/mod.rs (export components module) - src/tui/app.rs (add exec_cells field and event handlers) - src/tui/ui.rs (render exec cells in layout) All 500+ tests pass ✓ --- codi-rs/Cargo.toml | 1 + codi-rs/src/agent/mod.rs | 4 +- codi-rs/src/agent/types.rs | 10 +- codi-rs/src/orchestrate/child_agent.rs | 2 +- codi-rs/src/tui/app.rs | 51 +- codi-rs/src/tui/components/exec_cell.rs | 617 ++++++++++++++++++ codi-rs/src/tui/components/mod.rs | 23 + codi-rs/src/tui/mod.rs | 1 + codi-rs/src/tui/ui.rs | 131 +++- codi-rs/tests/exec_cell_unit.rs | 457 +++++++++++++ .../tui_exec_cell__exec_cell_error.snap | 25 + .../tui_exec_cell__exec_cell_expanded.snap | 30 + .../tui_exec_cell__exec_cell_live_output.snap | 25 + .../tui_exec_cell__exec_cell_pending.snap | 25 + .../tui_exec_cell__exec_cell_running.snap | 25 + .../tui_exec_cell__exec_cell_success.snap | 25 + codi-rs/tests/tui_exec_cell.rs | 158 +++++ 17 files changed, 1556 insertions(+), 54 deletions(-) create mode 100644 codi-rs/src/tui/components/exec_cell.rs create mode 100644 codi-rs/src/tui/components/mod.rs create mode 100644 codi-rs/tests/exec_cell_unit.rs create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap create mode 100644 codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap create mode 100644 codi-rs/tests/tui_exec_cell.rs diff --git a/codi-rs/Cargo.toml b/codi-rs/Cargo.toml index f5b49a7..a7ac00a 100644 --- a/codi-rs/Cargo.toml +++ b/codi-rs/Cargo.toml @@ -98,6 +98,7 @@ tempfile = "3" tokio-test = "0.4" wiremock = "0.6" criterion = { version = "0.6", features = ["async_tokio"] } +insta = { version = "1.41", features = ["yaml"] } [[bench]] name = "tools" diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 5584894..40faaa1 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -157,7 +157,7 @@ impl Agent { async fn execute_tool(&self, tool_call: &ToolCall) -> ToolResult { // Notify callback if let Some(ref on_tool_call) = self.callbacks.on_tool_call { - on_tool_call(&tool_call.name, &tool_call.input); + on_tool_call(&tool_call.id, &tool_call.name, &tool_call.input); } // Execute the tool @@ -190,7 +190,7 @@ impl Agent { // Notify callback if let Some(ref on_tool_result) = self.callbacks.on_tool_result { - on_tool_result(&tool_call.name, &result.content, result.is_error.unwrap_or(false)); + on_tool_result(&tool_call.id, &tool_call.name, &result.content, result.is_error.unwrap_or(false)); } result diff --git a/codi-rs/src/agent/types.rs b/codi-rs/src/agent/types.rs index b236988..66126b7 100644 --- a/codi-rs/src/agent/types.rs +++ b/codi-rs/src/agent/types.rs @@ -5,8 +5,8 @@ use std::sync::Arc; -use crate::types::{BoxedProvider, Message}; use crate::tools::ToolRegistry; +use crate::types::{BoxedProvider, Message}; /// Statistics for a single turn (user message -> final response). #[derive(Debug, Clone, Default)] @@ -66,10 +66,10 @@ pub enum ConfirmationResult { pub struct AgentCallbacks { /// Called when the model outputs text. pub on_text: Option>, - /// Called when a tool is about to be executed. - pub on_tool_call: Option>, - /// Called when a tool execution completes. - pub on_tool_result: Option>, + /// Called when a tool is about to be executed (tool_id, name, input). + pub on_tool_call: Option>, + /// Called when a tool execution completes (tool_id, name, result, is_error). + pub on_tool_result: Option>, /// Called to confirm destructive operations. Returns approval result. pub on_confirm: Option ConfirmationResult + Send + Sync>>, /// Called when context compaction starts/ends. diff --git a/codi-rs/src/orchestrate/child_agent.rs b/codi-rs/src/orchestrate/child_agent.rs index 46f1f56..3dd6eb7 100644 --- a/codi-rs/src/orchestrate/child_agent.rs +++ b/codi-rs/src/orchestrate/child_agent.rs @@ -214,7 +214,7 @@ impl ChildAgent { on_text: None, on_tool_call: Some(Box::new({ let ipc = Arc::clone(&self.ipc); - move |tool_name: &str, _input: &serde_json::Value| { + move |_tool_id: &str, tool_name: &str, _input: &serde_json::Value| { let ipc = Arc::clone(&ipc); let tool = tool_name.to_string(); tokio::spawn(async move { diff --git a/codi-rs/src/tui/app.rs b/codi-rs/src/tui/app.rs index 4d12c19..a697789 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -143,9 +143,11 @@ impl Message { pub enum AppEvent { /// Text delta received from streaming. TextDelta(String), - /// Tool call started. - ToolStart(String, serde_json::Value), - /// Tool call completed. + /// Tool call started (id, name, input). + ToolStart(String, String, serde_json::Value), + /// Tool output line received during execution. + ToolOutput(String, String), + /// Tool call completed (id, result, is_error). ToolResult(String, String, bool), /// Turn completed with stats. TurnComplete(TurnStats), @@ -207,6 +209,10 @@ pub struct App { /// Tab completion hint to display. pub completion_hint: Option, + // Tool execution visualization + /// Manager for tool execution cells. + pub exec_cells: crate::tui::components::ExecCellManager, + // Orchestration /// Commander for multi-agent orchestration. commander: Option, @@ -251,6 +257,7 @@ impl App { current_session: None, project_path, completion_hint: None, + exec_cells: crate::tui::components::ExecCellManager::new(), commander: None, pending_worker_permissions: Vec::new(), } @@ -284,14 +291,14 @@ impl App { })), on_tool_call: Some(Box::new({ let tx = event_tx.clone(); - move |name: &str, input: &serde_json::Value| { - let _ = tx.send(AppEvent::ToolStart(name.to_string(), input.clone())); + move |tool_id: &str, name: &str, input: &serde_json::Value| { + let _ = tx.send(AppEvent::ToolStart(tool_id.to_string(), name.to_string(), input.clone())); } })), on_tool_result: Some(Box::new({ let tx = event_tx.clone(); - move |name: &str, result: &str, is_error: bool| { - let _ = tx.send(AppEvent::ToolResult(name.to_string(), result.to_string(), is_error)); + move |tool_id: &str, _name: &str, result: &str, is_error: bool| { + let _ = tx.send(AppEvent::ToolResult(tool_id.to_string(), result.to_string(), is_error)); } })), on_confirm: None, // Handled via channel-based approach @@ -364,14 +371,32 @@ impl App { AppEvent::TextDelta(text) => { self.handle_text_delta(&text); } - AppEvent::ToolStart(name, _input) => { + AppEvent::ToolStart(id, name, input) => { + // Create a new exec cell for this tool + let cell = crate::tui::components::ExecCell::new( + id.clone(), + name.clone(), + input, + ); + self.exec_cells.add(cell); self.status = Some(format!("Running: {} ...", name)); } - AppEvent::ToolResult(name, _result, is_error) => { - if is_error { - self.status = Some(format!("Tool {} failed", name)); - } else { - self.status = Some(format!("Completed: {}", name)); + AppEvent::ToolOutput(id, line) => { + // Add output line to the exec cell + if let Some(cell) = self.exec_cells.get_mut(&id) { + cell.add_output_line(line); + } + } + AppEvent::ToolResult(id, result, is_error) => { + // Update the exec cell with the result + if let Some(cell) = self.exec_cells.get_mut(&id) { + if is_error { + cell.mark_error(&result); + self.status = Some(format!("Tool {} failed", cell.tool_name)); + } else { + cell.mark_success(&result); + self.status = Some(format!("Completed: {}", cell.tool_name)); + } } } AppEvent::TurnComplete(stats) => { diff --git a/codi-rs/src/tui/components/exec_cell.rs b/codi-rs/src/tui/components/exec_cell.rs new file mode 100644 index 0000000..14c5223 --- /dev/null +++ b/codi-rs/src/tui/components/exec_cell.rs @@ -0,0 +1,617 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Tool execution visualization component. +//! +//! ExecCell provides rich visual display for tool calls with: +//! - Animated spinners during execution +//! - Live output streaming +//! - Duration tracking +//! - Collapsible full output +//! - Color-coded status (pending/running/success/error) + +use std::time::{Duration, Instant}; + +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Paragraph, StatefulWidget, Widget, Wrap}, +}; + +/// Status of a tool execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + /// Tool is queued but not yet started. + Pending, + /// Tool is currently executing. + Running, + /// Tool completed successfully. + Success, + /// Tool failed with an error. + Error, +} + +impl ToolStatus { + /// Get the color associated with this status. + pub fn color(&self) -> Color { + match self { + ToolStatus::Pending => Color::Gray, + ToolStatus::Running => Color::Yellow, + ToolStatus::Success => Color::Green, + ToolStatus::Error => Color::Red, + } + } + + /// Get the icon character for this status. + pub fn icon(&self) -> char { + match self { + ToolStatus::Pending => '○', + ToolStatus::Running => '◐', + ToolStatus::Success => '✓', + ToolStatus::Error => '✗', + } + } + + /// Check if the tool is in a terminal state. + pub fn is_terminal(&self) -> bool { + matches!(self, ToolStatus::Success | ToolStatus::Error) + } +} + +/// A single tool execution cell. +#[derive(Debug, Clone)] +pub struct ExecCell { + /// Unique identifier for this execution. + pub id: String, + /// Name of the tool being executed. + pub tool_name: String, + /// Input parameters (JSON value). + pub input: serde_json::Value, + /// Current execution status. + pub status: ToolStatus, + /// When execution started. + pub start_time: Instant, + /// When execution completed (if finished). + pub end_time: Option, + /// Live output lines (captured during execution). + pub live_output: Vec, + /// Maximum number of live output lines to keep. + max_live_output: usize, + /// Full result output (shown when expanded). + pub result: Option, + /// Whether the cell is expanded to show full output. + pub expanded: bool, + /// Current spinner frame (for animation). + pub spinner_frame: usize, +} + +impl ExecCell { + /// Spinner animation characters. + const SPINNER_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + + /// Create a new exec cell for a pending tool call. + pub fn new( + id: impl Into, + tool_name: impl Into, + input: serde_json::Value, + ) -> Self { + Self { + id: id.into(), + tool_name: tool_name.into(), + input, + status: ToolStatus::Pending, + start_time: Instant::now(), + end_time: None, + live_output: Vec::new(), + max_live_output: 5, + result: None, + expanded: false, + spinner_frame: 0, + } + } + + /// Mark the tool as running. + pub fn mark_running(&mut self) { + self.status = ToolStatus::Running; + self.start_time = Instant::now(); + } + + /// Mark the tool as completed successfully. + pub fn mark_success(&mut self, result: impl Into) { + self.status = ToolStatus::Success; + self.end_time = Some(Instant::now()); + self.result = Some(result.into()); + } + + /// Mark the tool as failed. + pub fn mark_error(&mut self, error: impl Into) { + self.status = ToolStatus::Error; + self.end_time = Some(Instant::now()); + self.result = Some(error.into()); + } + + /// Add a line of live output during execution. + pub fn add_output_line(&mut self, line: impl Into) { + if self.live_output.len() >= self.max_live_output { + self.live_output.remove(0); + } + self.live_output.push(line.into()); + } + + /// Add multiple lines of live output. + pub fn add_output_lines(&mut self, lines: impl Iterator>) { + for line in lines { + self.add_output_line(line); + } + } + + /// Toggle expanded state. + pub fn toggle_expanded(&mut self) { + self.expanded = !self.expanded; + } + + /// Get the current duration of execution. + pub fn duration(&self) -> Duration { + match self.end_time { + Some(end) => end.duration_since(self.start_time), + None => self.start_time.elapsed(), + } + } + + /// Format duration as human-readable string. + pub fn format_duration(&self) -> String { + let dur = self.duration(); + if dur.as_secs() > 0 { + format!("{:.1}s", dur.as_secs_f64()) + } else { + format!("{}ms", dur.as_millis()) + } + } + + /// Get a preview of the input (truncated). + pub fn input_preview(&self, max_len: usize) -> String { + let input_str = self.input.to_string(); + if input_str.len() <= max_len { + input_str + } else { + format!("{}...", &input_str[..max_len.saturating_sub(3)]) + } + } + + /// Advance the spinner animation. + pub fn tick_spinner(&mut self) { + self.spinner_frame = (self.spinner_frame + 1) % Self::SPINNER_CHARS.len(); + } + + /// Get the current spinner character. + pub fn spinner_char(&self) -> char { + if self.status == ToolStatus::Running { + Self::SPINNER_CHARS[self.spinner_frame] + } else { + self.status.icon() + } + } + + /// Calculate the height needed to render this cell. + pub fn required_height(&self, width: u16) -> u16 { + let base_height = 3; // Header + border + + let input_height = if self.expanded { + // Full input JSON + let input_str = self.input.to_string(); + let lines = input_str.lines().count() as u16; + lines + 1 // +1 for label + } else { + 1 // Single line preview + }; + + let output_height = if self.expanded && self.result.is_some() { + let result_lines = self.result.as_ref().unwrap().lines().count() as u16; + result_lines.min(20) + 1 // +1 for label, max 20 lines + } else if !self.live_output.is_empty() && self.status == ToolStatus::Running { + // Show live output preview + (self.live_output.len() as u16).min(self.max_live_output as u16) + 2 + // +2 for border + } else if self.status.is_terminal() { + 1 // Result summary line + } else { + 0 + }; + + base_height + input_height + output_height + 2 // +2 for padding + } +} + +/// A widget for rendering an exec cell. +pub struct ExecCellWidget; + +impl ExecCellWidget { + /// Render the cell at the given area. + pub fn render(cell: &ExecCell, area: Rect, buf: &mut Buffer) { + // Determine border style based on status + let border_color = cell.status.color(); + let border_style = Style::default().fg(border_color); + + // Create block with title + let title = format!(" {} ", cell.tool_name); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )) + .title_alignment(ratatui::layout::Alignment::Left); + + // Render block + block.render(area, buf); + + // Get inner area + let inner = area.inner(Margin::new(2, 1)); + + // Split inner area into sections + let sections = if cell.expanded { + // Expanded: show full input and output + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header (icon + duration) + Constraint::Min(1), // Input + Constraint::Min(1), // Output (if available) + ]) + .split(inner) + } else { + // Collapsed: compact view + let has_output = !cell.live_output.is_empty() || cell.result.is_some(); + if has_output && cell.status == ToolStatus::Running { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Length(1), // Input preview + Constraint::Min(1), // Live output + ]) + .split(inner) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Length(1), // Input preview + Constraint::Length(1), // Result summary (if terminal) + ]) + .split(inner) + } + }; + + // Render header line (icon + status + duration) + Self::render_header(cell, sections[0], buf); + + // Render input section + Self::render_input(cell, sections[1], buf); + + // Render output section if applicable + if sections.len() > 2 { + Self::render_output(cell, sections[2], buf); + } + } + + fn render_header(cell: &ExecCell, area: Rect, buf: &mut Buffer) { + let spinner = cell.spinner_char(); + let duration = cell.format_duration(); + + let header_text = if cell.status.is_terminal() { + format!("{} {} ({})", spinner, cell.status_icon_text(), duration) + } else { + format!("{} Running... ({})", spinner, duration) + }; + + let header_style = Style::default().fg(cell.status.color()); + let header = Paragraph::new(Line::from(vec![ + Span::styled( + format!("{} ", spinner), + header_style.add_modifier(Modifier::BOLD), + ), + Span::styled( + if cell.status.is_terminal() { + cell.status_icon_text() + } else { + "Running...".to_string() + }, + header_style, + ), + Span::styled(format!(" ({})", duration), Style::default().fg(Color::Gray)), + ])); + + header.render(area, buf); + } + + fn render_input(cell: &ExecCell, area: Rect, buf: &mut Buffer) { + if cell.expanded { + // Show full input as JSON + let input_str = serde_json::to_string_pretty(&cell.input).unwrap_or_default(); + let input_paragraph = Paragraph::new(input_str) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Gray)); + input_paragraph.render(area, buf); + } else { + // Show truncated preview + let preview = cell.input_preview(area.width as usize); + let preview_line = Line::from(vec![ + Span::styled("Input: ", Style::default().fg(Color::DarkGray)), + Span::styled(preview, Style::default().fg(Color::Gray)), + ]); + buf.set_line(area.x, area.y, &preview_line, area.width); + } + } + + fn render_output(cell: &ExecCell, area: Rect, buf: &mut Buffer) { + if cell.expanded && cell.result.is_some() { + // Show full result + let result = cell.result.as_ref().unwrap(); + let lines: Vec = result + .lines() + .take(20) // Limit to 20 lines in expanded view + .map(|line| Line::from(Span::raw(line.to_string()))) + .collect(); + + let output_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Gray)) + .title(" Output "); + + let output_area = area; + output_block.render(output_area, buf); + + let inner = output_area.inner(Margin::new(2, 1)); + let output_text = Text::from(lines); + let output_paragraph = Paragraph::new(output_text).wrap(Wrap { trim: true }); + output_paragraph.render(inner, buf); + } else if !cell.live_output.is_empty() && cell.status == ToolStatus::Running { + // Show live output preview + let output_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let output_area = area; + output_block.render(output_area, buf); + + let inner = output_area.inner(Margin::new(2, 1)); + let lines: Vec = cell + .live_output + .iter() + .map(|line| { + Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))) + }) + .collect(); + + let output_text = Text::from(lines); + let output_paragraph = Paragraph::new(output_text).wrap(Wrap { trim: true }); + output_paragraph.render(inner, buf); + } else if cell.status.is_terminal() { + // Show result summary + let summary = if let Some(ref result) = cell.result { + let lines = result.lines().count(); + let preview: String = result.chars().take(100).collect(); + if result.len() > 100 { + format!("{} lines | {}...", lines, preview) + } else { + format!("{} lines | {}", lines, preview) + } + } else { + "No output".to_string() + }; + + let summary_line = Line::from(vec![ + Span::styled("Result: ", Style::default().fg(Color::DarkGray)), + Span::styled(summary, Style::default().fg(Color::Gray)), + ]); + buf.set_line(area.x, area.y, &summary_line, area.width); + } + } +} + +impl ExecCell { + fn status_icon_text(&self) -> String { + match self.status { + ToolStatus::Pending => "Pending".to_string(), + ToolStatus::Running => "Running".to_string(), + ToolStatus::Success => "Success".to_string(), + ToolStatus::Error => "Error".to_string(), + } + } +} + +/// Manager for multiple exec cells. +#[derive(Debug, Default)] +pub struct ExecCellManager { + cells: Vec, +} + +impl ExecCellManager { + /// Create a new empty manager. + pub fn new() -> Self { + Self { cells: Vec::new() } + } + + /// Add a new exec cell. + pub fn add(&mut self, cell: ExecCell) -> String { + let id = cell.id.clone(); + self.cells.push(cell); + id + } + + /// Get a cell by ID. + pub fn get(&self, id: &str) -> Option<&ExecCell> { + self.cells.iter().find(|c| c.id == id) + } + + /// Get a mutable cell by ID. + pub fn get_mut(&mut self, id: &str) -> Option<&mut ExecCell> { + self.cells.iter_mut().find(|c| c.id == id) + } + + /// Remove a cell by ID. + pub fn remove(&mut self, id: &str) -> Option { + if let Some(index) = self.cells.iter().position(|c| c.id == id) { + Some(self.cells.remove(index)) + } else { + None + } + } + + /// Get all cells. + pub fn cells(&self) -> &[ExecCell] { + &self.cells + } + + /// Get mutable access to all cells. + pub fn cells_mut(&mut self) -> &mut [ExecCell] { + &mut self.cells + } + + /// Clear completed cells older than a given duration. + pub fn clear_old_completed(&mut self, max_age: Duration) { + let now = Instant::now(); + self.cells.retain(|c| { + if c.status.is_terminal() { + if let Some(end_time) = c.end_time { + now.duration_since(end_time) < max_age + } else { + true + } + } else { + true + } + }); + } + + /// Get cells that are still running. + pub fn running_cells(&self) -> Vec<&ExecCell> { + self.cells + .iter() + .filter(|c| c.status == ToolStatus::Running) + .collect() + } + + /// Get count of running cells. + pub fn running_count(&self) -> usize { + self.cells + .iter() + .filter(|c| c.status == ToolStatus::Running) + .count() + } + + /// Tick all running cell spinners. + pub fn tick_all_spinners(&mut self) { + for cell in self.cells.iter_mut() { + if cell.status == ToolStatus::Running { + cell.tick_spinner(); + } + } + } + + /// Calculate total height needed for all cells. + pub fn total_height(&self, width: u16) -> u16 { + self.cells + .iter() + .map(|c| c.required_height(width) + 1) + .sum::() // +1 for spacing + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_status_colors() { + assert_eq!(ToolStatus::Pending.color(), Color::Gray); + assert_eq!(ToolStatus::Running.color(), Color::Yellow); + assert_eq!(ToolStatus::Success.color(), Color::Green); + assert_eq!(ToolStatus::Error.color(), Color::Red); + } + + #[test] + fn test_exec_cell_lifecycle() { + let mut cell = ExecCell::new("1", "bash", serde_json::json!({"cmd": "echo hi"})); + + assert_eq!(cell.status, ToolStatus::Pending); + assert!(cell.end_time.is_none()); + + cell.mark_running(); + assert_eq!(cell.status, ToolStatus::Running); + + cell.mark_success("output"); + assert_eq!(cell.status, ToolStatus::Success); + assert!(cell.end_time.is_some()); + assert_eq!(cell.result, Some("output".to_string())); + } + + #[test] + fn test_exec_cell_live_output() { + let mut cell = ExecCell::new("1", "bash", serde_json::json!({})); + + cell.add_output_line("line 1"); + cell.add_output_line("line 2"); + cell.add_output_line("line 3"); + + assert_eq!(cell.live_output.len(), 3); + + // Add more than max + cell.add_output_line("line 4"); + cell.add_output_line("line 5"); + cell.add_output_line("line 6"); + + // Should only keep last 5 + assert_eq!(cell.live_output.len(), 5); + assert_eq!(cell.live_output[0], "line 2"); + } + + #[test] + fn test_manager_add_and_get() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new( + "test-1", + "read_file", + serde_json::json!({"path": "test.rs"}), + ); + + let id = manager.add(cell); + assert_eq!(id, "test-1"); + + let retrieved = manager.get("test-1"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().tool_name, "read_file"); + } + + #[test] + fn test_manager_running_count() { + let mut manager = ExecCellManager::new(); + + let mut cell1 = ExecCell::new("1", "bash", serde_json::json!({})); + cell1.mark_running(); + manager.add(cell1); + + let mut cell2 = ExecCell::new("2", "grep", serde_json::json!({})); + cell2.mark_success("done"); + manager.add(cell2); + + assert_eq!(manager.running_count(), 1); + } + + #[test] + fn test_spinner_animation() { + let mut cell = ExecCell::new("1", "bash", serde_json::json!({})); + cell.mark_running(); + + let frame1 = cell.spinner_frame; + cell.tick_spinner(); + let frame2 = cell.spinner_frame; + + assert_ne!(frame1, frame2); + assert!(cell.spinner_char() != '○'); + } +} diff --git a/codi-rs/src/tui/components/mod.rs b/codi-rs/src/tui/components/mod.rs new file mode 100644 index 0000000..c89d79a --- /dev/null +++ b/codi-rs/src/tui/components/mod.rs @@ -0,0 +1,23 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! TUI components module. +//! +//! This module provides reusable UI components for the Codi TUI. + +pub mod exec_cell; + +pub use exec_cell::{ExecCell, ExecCellManager, ExecCellWidget, ToolStatus}; + +/// Snapshot testing utilities for TUI components. +#[cfg(test)] +pub mod testing { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + /// Create a test terminal for snapshot testing. + pub fn test_terminal(width: u16, height: u16) -> Terminal { + let backend = TestBackend::new(width, height); + Terminal::new(backend).unwrap() + } +} diff --git a/codi-rs/src/tui/mod.rs b/codi-rs/src/tui/mod.rs index 8e270da..a02f455 100644 --- a/codi-rs/src/tui/mod.rs +++ b/codi-rs/src/tui/mod.rs @@ -37,6 +37,7 @@ pub mod app; pub mod commands; +pub mod components; pub mod events; pub mod streaming; pub mod ui; diff --git a/codi-rs/src/tui/ui.rs b/codi-rs/src/tui/ui.rs index 5dd058f..43e91f1 100644 --- a/codi-rs/src/tui/ui.rs +++ b/codi-rs/src/tui/ui.rs @@ -14,21 +14,45 @@ use ratatui::{ use crate::types::Role; use super::app::{App, AppMode}; +use super::components::{ExecCellWidget, ToolStatus}; /// Draw the main UI. pub fn draw(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // Messages area - Constraint::Length(3), // Input area - Constraint::Length(1), // Status bar - ]) - .split(f.area()); + let has_exec_cells = !app.exec_cells.cells().is_empty(); + + let chunks = if has_exec_cells { + // Split to show exec cells between messages and input + let exec_height = app.exec_cells.total_height(f.area().width).min(10); + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Messages area + Constraint::Length(exec_height), // Tool execution cells + Constraint::Length(3), // Input area + Constraint::Length(1), // Status bar + ]) + .split(f.area()) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Messages area + Constraint::Length(3), // Input area + Constraint::Length(1), // Status bar + ]) + .split(f.area()) + }; draw_messages(f, app, chunks[0]); - draw_input(f, app, chunks[1]); - draw_status(f, app, chunks[2]); + + if has_exec_cells { + draw_exec_cells(f, app, chunks[1]); + draw_input(f, app, chunks[2]); + draw_status(f, app, chunks[3]); + } else { + draw_input(f, app, chunks[1]); + draw_status(f, app, chunks[2]); + } // Draw overlays match app.mode { @@ -43,7 +67,11 @@ fn draw_messages(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Conversation ") - .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); let inner = block.inner(area); @@ -58,9 +86,10 @@ fn draw_messages(f: &mut Frame, app: &App, area: Rect) { }; // Add prefix line - lines.push(Line::from(vec![ - Span::styled(prefix, style.add_modifier(Modifier::BOLD)), - ])); + lines.push(Line::from(vec![Span::styled( + prefix, + style.add_modifier(Modifier::BOLD), + )])); // Use pre-rendered lines if available (from streaming) if !msg.rendered_lines.is_empty() { @@ -131,10 +160,7 @@ fn draw_input(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::Yellow), ), AppMode::Help => (" Help ", Style::default().fg(Color::Cyan)), - AppMode::ConfirmTool => ( - " Confirm Tool ", - Style::default().fg(Color::Red), - ), + AppMode::ConfirmTool => (" Confirm Tool ", Style::default().fg(Color::Red)), }; let block = Block::default() @@ -150,10 +176,7 @@ fn draw_input(f: &mut Frame, app: &App, area: Rect) { // Show cursor in normal mode if app.mode == AppMode::Normal { - f.set_cursor_position(( - area.x + 1 + app.cursor_pos as u16, - area.y + 1, - )); + f.set_cursor_position((area.x + 1 + app.cursor_pos as u16, area.y + 1)); } } @@ -187,17 +210,14 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled( format!( " | {} tools, {} in, {} out", - stats.tool_call_count, - stats.input_tokens, - stats.output_tokens + stats.tool_call_count, stats.input_tokens, stats.output_tokens ), Style::default().fg(Color::DarkGray), )); } } - let status = Paragraph::new(Line::from(spans)) - .style(Style::default().bg(Color::DarkGray)); + let status = Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::DarkGray)); f.render_widget(status, area); } @@ -360,9 +380,7 @@ fn draw_confirmation(f: &mut Frame, app: &App) { let mut lines = vec![ Line::from(Span::styled( " Tool Confirmation Required ", - Style::default() - .fg(Color::Red) - .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(vec![ @@ -395,11 +413,24 @@ fn draw_confirmation(f: &mut Frame, app: &App) { lines.extend(vec![ Line::from(""), Line::from(vec![ - Span::styled("[Y]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::styled( + "[Y]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Approve "), - Span::styled("[N]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled( + "[N]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), Span::raw(" Deny "), - Span::styled("[A]", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled( + "[A]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Abort"), ]), ]); @@ -416,6 +447,40 @@ fn draw_confirmation(f: &mut Frame, app: &App) { f.render_widget(confirmation_widget, area); } +/// Draw the exec cells area. +fn draw_exec_cells(f: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Tools ") + .title_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Render each exec cell + let mut current_y = inner.y; + for cell in app.exec_cells.cells() { + let cell_height = cell.required_height(inner.width); + if current_y + cell_height > inner.bottom() { + break; // Don't overflow + } + + let cell_area = Rect { + x: inner.x, + y: current_y, + width: inner.width, + height: cell_height, + }; + + ExecCellWidget::render(cell, cell_area, f.buffer_mut()); + current_y += cell_height + 1; // +1 for spacing + } +} + /// Create a centered rectangle. fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() diff --git a/codi-rs/tests/exec_cell_unit.rs b/codi-rs/tests/exec_cell_unit.rs new file mode 100644 index 0000000..aebe5b1 --- /dev/null +++ b/codi-rs/tests/exec_cell_unit.rs @@ -0,0 +1,457 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Comprehensive tests for ExecCell and ExecCellManager. + +use std::time::Duration; + +use codi::tui::components::{ExecCell, ExecCellManager, ToolStatus}; + +// ============================================================================ +// ToolStatus Tests +// ============================================================================ + +#[test] +fn test_tool_status_color() { + assert_eq!(ToolStatus::Pending.color(), ratatui::style::Color::Gray); + assert_eq!(ToolStatus::Running.color(), ratatui::style::Color::Yellow); + assert_eq!(ToolStatus::Success.color(), ratatui::style::Color::Green); + assert_eq!(ToolStatus::Error.color(), ratatui::style::Color::Red); +} + +#[test] +fn test_tool_status_icon() { + assert_eq!(ToolStatus::Pending.icon(), '○'); + assert_eq!(ToolStatus::Running.icon(), '◐'); + assert_eq!(ToolStatus::Success.icon(), '✓'); + assert_eq!(ToolStatus::Error.icon(), '✗'); +} + +#[test] +fn test_tool_status_is_terminal() { + assert!(!ToolStatus::Pending.is_terminal()); + assert!(!ToolStatus::Running.is_terminal()); + assert!(ToolStatus::Success.is_terminal()); + assert!(ToolStatus::Error.is_terminal()); +} + +// ============================================================================ +// ExecCell Lifecycle Tests +// ============================================================================ + +#[test] +fn test_exec_cell_new() { + let cell = ExecCell::new( + "test-id", + "read_file", + serde_json::json!({"path": "test.rs"}), + ); + + assert_eq!(cell.id, "test-id"); + assert_eq!(cell.tool_name, "read_file"); + assert_eq!(cell.status, ToolStatus::Pending); + assert!(cell.end_time.is_none()); + assert!(cell.result.is_none()); + assert!(!cell.expanded); + assert!(cell.live_output.is_empty()); +} + +#[test] +fn test_exec_cell_mark_running() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + assert_eq!(cell.status, ToolStatus::Running); + assert!(cell.end_time.is_none()); +} + +#[test] +fn test_exec_cell_mark_success() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + cell.mark_success("result content"); + + assert_eq!(cell.status, ToolStatus::Success); + assert!(cell.end_time.is_some()); + assert_eq!(cell.result, Some("result content".to_string())); +} + +#[test] +fn test_exec_cell_mark_error() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + cell.mark_error("something went wrong"); + + assert_eq!(cell.status, ToolStatus::Error); + assert!(cell.end_time.is_some()); + assert_eq!(cell.result, Some("something went wrong".to_string())); +} + +// ============================================================================ +// ExecCell Output Tests +// ============================================================================ + +#[test] +fn test_exec_cell_add_output_line() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + + cell.add_output_line("line 1"); + assert_eq!(cell.live_output.len(), 1); + assert_eq!(cell.live_output[0], "line 1"); + + cell.add_output_line("line 2"); + assert_eq!(cell.live_output.len(), 2); + assert_eq!(cell.live_output[1], "line 2"); +} + +#[test] +fn test_exec_cell_live_output_max_size() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + + // Add more than 5 lines (max) + for i in 0..7 { + cell.add_output_line(format!("line {}", i)); + } + + assert_eq!(cell.live_output.len(), 5); + // First two should be removed (FIFO) + assert_eq!(cell.live_output[0], "line 2"); + assert_eq!(cell.live_output[4], "line 6"); +} + +#[test] +fn test_exec_cell_add_output_lines() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + + let lines = vec!["a", "b", "c"]; + cell.add_output_lines(lines.into_iter().map(|s| s.to_string())); + + assert_eq!(cell.live_output.len(), 3); + assert_eq!(cell.live_output[0], "a"); + assert_eq!(cell.live_output[2], "c"); +} + +// ============================================================================ +// ExecCell Display Tests +// ============================================================================ + +#[test] +fn test_exec_cell_input_preview_short() { + let cell = ExecCell::new("id", "tool", serde_json::json!({"key": "value"})); + + let preview = cell.input_preview(100); + assert!(preview.contains("key")); + assert!(preview.contains("value")); +} + +#[test] +fn test_exec_cell_input_preview_truncated() { + let cell = ExecCell::new("id", "tool", serde_json::json!({"long": "a".repeat(200)})); + + let preview = cell.input_preview(50); + assert!(preview.ends_with("...")); + assert_eq!(preview.len(), 50); +} + +#[test] +fn test_exec_cell_format_duration_milliseconds() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + // Very short duration + std::thread::sleep(Duration::from_millis(5)); + let dur = cell.format_duration(); + assert!(dur.ends_with("ms")); +} + +#[test] +fn test_exec_cell_format_duration_seconds() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + // Mock a longer duration by setting end_time + std::thread::sleep(Duration::from_millis(1500)); + cell.mark_success("done"); + + let dur = cell.format_duration(); + assert!(dur.ends_with("s")); +} + +// ============================================================================ +// ExecCell State Tests +// ============================================================================ + +#[test] +fn test_exec_cell_toggle_expanded() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + + assert!(!cell.expanded); + cell.toggle_expanded(); + assert!(cell.expanded); + cell.toggle_expanded(); + assert!(!cell.expanded); +} + +#[test] +fn test_exec_cell_spinner_animation() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + let frame1 = cell.spinner_frame; + let char1 = cell.spinner_char(); + + cell.tick_spinner(); + + let frame2 = cell.spinner_frame; + let char2 = cell.spinner_char(); + + assert_ne!(frame1, frame2); + assert_ne!(char1, char2); + + // Should cycle back + for _ in 0..10 { + cell.tick_spinner(); + } + assert_eq!(cell.spinner_frame, frame1); +} + +#[test] +fn test_exec_cell_spinner_not_running() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + // Not running - should show status icon + + let char1 = cell.spinner_char(); + cell.tick_spinner(); + let char2 = cell.spinner_char(); + + // When not running, spinner doesn't change + assert_eq!(char1, char2); + assert_eq!(char1, '○'); // Pending icon +} + +#[test] +fn test_exec_cell_duration_calculation() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + std::thread::sleep(Duration::from_millis(10)); + let dur1 = cell.duration(); + + std::thread::sleep(Duration::from_millis(10)); + let dur2 = cell.duration(); + + assert!(dur2 > dur1); +} + +// ============================================================================ +// ExecCellManager Tests +// ============================================================================ + +#[test] +fn test_manager_new() { + let manager = ExecCellManager::new(); + assert!(manager.cells().is_empty()); + assert_eq!(manager.running_count(), 0); +} + +#[test] +fn test_manager_add() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new("test-1", "tool", serde_json::json!({})); + + let id = manager.add(cell); + assert_eq!(id, "test-1"); + assert_eq!(manager.cells().len(), 1); +} + +#[test] +fn test_manager_get() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new("test-1", "tool", serde_json::json!({})); + manager.add(cell); + + let retrieved = manager.get("test-1"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().id, "test-1"); + + let not_found = manager.get("nonexistent"); + assert!(not_found.is_none()); +} + +#[test] +fn test_manager_get_mut() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new("test-1", "tool", serde_json::json!({})); + manager.add(cell); + + let cell_mut = manager.get_mut("test-1").unwrap(); + cell_mut.mark_running(); + + let retrieved = manager.get("test-1").unwrap(); + assert_eq!(retrieved.status, ToolStatus::Running); +} + +#[test] +fn test_manager_remove() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new("test-1", "tool", serde_json::json!({})); + manager.add(cell); + + let removed = manager.remove("test-1"); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().id, "test-1"); + assert!(manager.get("test-1").is_none()); + + let not_found = manager.remove("nonexistent"); + assert!(not_found.is_none()); +} + +#[test] +fn test_manager_running_count() { + let mut manager = ExecCellManager::new(); + + let mut cell1 = ExecCell::new("1", "tool", serde_json::json!({})); + cell1.mark_running(); + manager.add(cell1); + + let mut cell2 = ExecCell::new("2", "tool", serde_json::json!({})); + cell2.mark_success("done"); + manager.add(cell2); + + let mut cell3 = ExecCell::new("3", "tool", serde_json::json!({})); + cell3.mark_running(); + manager.add(cell3); + + assert_eq!(manager.running_count(), 2); +} + +#[test] +fn test_manager_running_cells() { + let mut manager = ExecCellManager::new(); + + let mut cell1 = ExecCell::new("1", "tool", serde_json::json!({})); + cell1.mark_running(); + manager.add(cell1); + + let cell2 = ExecCell::new("2", "tool", serde_json::json!({})); + manager.add(cell2); + + let running = manager.running_cells(); + assert_eq!(running.len(), 1); + assert_eq!(running[0].id, "1"); +} + +#[test] +fn test_manager_tick_all_spinners() { + let mut manager = ExecCellManager::new(); + + let mut cell1 = ExecCell::new("1", "tool", serde_json::json!({})); + cell1.mark_running(); + manager.add(cell1); + + let cell2 = ExecCell::new("2", "tool", serde_json::json!({})); + manager.add(cell2); + + let frame_before = manager.get("1").unwrap().spinner_frame; + manager.tick_all_spinners(); + let frame_after = manager.get("1").unwrap().spinner_frame; + + assert_ne!(frame_before, frame_after); +} + +#[test] +fn test_manager_clear_old_completed() { + let mut manager = ExecCellManager::new(); + + // Old completed cell (mock by creating and completing immediately) + let mut cell1 = ExecCell::new("1", "tool", serde_json::json!({})); + cell1.mark_running(); + cell1.mark_success("done"); + // Manually set end_time to be old + cell1.end_time = Some(std::time::Instant::now() - Duration::from_secs(100)); + manager.add(cell1); + + // Recent running cell + let mut cell2 = ExecCell::new("2", "tool", serde_json::json!({})); + cell2.mark_running(); + manager.add(cell2); + + manager.clear_old_completed(Duration::from_secs(10)); + + assert!(manager.get("1").is_none()); // Old cell cleared + assert!(manager.get("2").is_some()); // Running cell kept +} + +#[test] +fn test_manager_total_height() { + let mut manager = ExecCellManager::new(); + + let cell1 = ExecCell::new("1", "tool", serde_json::json!({})); + manager.add(cell1); + + let mut cell2 = ExecCell::new("2", "tool", serde_json::json!({})); + cell2.mark_success("result\nwith\nmultiple\nlines"); + manager.add(cell2); + + let height = manager.total_height(80); + assert!(height > 0); +} + +// ============================================================================ +// Edge Cases +// ============================================================================ + +#[test] +fn test_exec_cell_empty_result() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + cell.mark_success(""); + + assert_eq!(cell.result, Some("".to_string())); + assert_eq!(cell.status, ToolStatus::Success); +} + +#[test] +fn test_exec_cell_multiline_result() { + let mut cell = ExecCell::new("id", "tool", serde_json::json!({})); + cell.mark_running(); + + let multiline = "line 1\nline 2\nline 3\nline 4\nline 5"; + cell.mark_success(multiline); + + assert_eq!(cell.result.unwrap().lines().count(), 5); +} + +#[test] +fn test_exec_cell_complex_json_input() { + let cell = ExecCell::new( + "id", + "write_file", + serde_json::json!({ + "path": "test.txt", + "content": "Hello World", + "options": { + "overwrite": true, + "backup": false + } + }), + ); + + let preview = cell.input_preview(1000); + assert!(preview.contains("path")); + assert!(preview.contains("content")); + assert!(preview.contains("options")); +} + +#[test] +fn test_manager_cells_mut() { + let mut manager = ExecCellManager::new(); + let cell = ExecCell::new("1", "tool", serde_json::json!({})); + manager.add(cell); + + for cell in manager.cells_mut() { + cell.mark_running(); + } + + assert_eq!(manager.get("1").unwrap().status, ToolStatus::Running); +} diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap new file mode 100644 index 0000000..0f70249 --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap @@ -0,0 +1,25 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 99 +expression: terminal.backend() +--- +"┌ bash ────────────────────────────────────────────────────────────────────────┐" +"│ ✗ Error (0ms) │" +"│ Input: {"cmd":"invalid_command"} │" +"│ Result: 1 lines | Command not found: invalid_command │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap new file mode 100644 index 0000000..48c8d11 --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap @@ -0,0 +1,30 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 127 +expression: terminal.backend() +--- +"┌ write_file ──────────────────────────────────────────────────────────────────┐" +"│ ✓ Success (0ms) │" +"│ { │" +"│ "content": "Hello World", │" +"│ "path": "output.txt" │" +"│ } │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ ┌ Output ──────────────────────────────────────────────────────────────────┐ │" +"│ │ File written successfully │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap new file mode 100644 index 0000000..88a6dc8 --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap @@ -0,0 +1,25 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 157 +expression: terminal.backend() +--- +"┌ bash ────────────────────────────────────────────────────────────────────────┐" +"│ ⠋ Running... (1ms) │" +"│ Input: {"cmd":"long_running_command"} │" +"│ ┌──────────────────────────────────────────────────────────────────────────┐ │" +"│ │ Starting process... │ │" +"│ │ Loading configuration │ │" +"│ │ Connecting to database │ │" +"│ │ Executing query │ │" +"│ │ Processing results │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap new file mode 100644 index 0000000..0f1f633 --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap @@ -0,0 +1,25 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 30 +expression: terminal.backend() +--- +"┌ read_file ───────────────────────────────────────────────────────────────────┐" +"│ ○ Running... (1ms) │" +"│ Input: {"path":"test.rs"} │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap new file mode 100644 index 0000000..a4579ba --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap @@ -0,0 +1,25 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 51 +expression: terminal.backend() +--- +"┌ bash ────────────────────────────────────────────────────────────────────────┐" +"│ ⠋ Running... (2ms) │" +"│ Input: {"cmd":"echo hello"} │" +"│ ┌──────────────────────────────────────────────────────────────────────────┐ │" +"│ │ Processing... │ │" +"│ │ Step 1 complete │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ │ │ │" +"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap new file mode 100644 index 0000000..c30e070 --- /dev/null +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap @@ -0,0 +1,25 @@ +--- +source: tests/tui_exec_cell.rs +assertion_line: 75 +expression: terminal.backend() +--- +"┌ read_file ───────────────────────────────────────────────────────────────────┐" +"│ ✓ Success (0ms) │" +"│ Input: {"path":"test.rs"} │" +"│ Result: 3 lines | File content hereMultiple linesOf text │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/tui_exec_cell.rs b/codi-rs/tests/tui_exec_cell.rs new file mode 100644 index 0000000..da75f35 --- /dev/null +++ b/codi-rs/tests/tui_exec_cell.rs @@ -0,0 +1,158 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! TUI rendering integration tests using insta snapshots. + +use ratatui::backend::TestBackend; +use ratatui::Terminal; + +use codi::tui::components::{ExecCell, ExecCellWidget}; + +/// Test rendering of a pending exec cell. +#[test] +fn test_exec_cell_pending() { + let cell = ExecCell::new( + "test-1", + "read_file", + serde_json::json!({"path": "test.rs"}), + ); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +/// Test rendering of a running exec cell with spinner. +#[test] +fn test_exec_cell_running() { + let mut cell = ExecCell::new("test-1", "bash", serde_json::json!({"cmd": "echo hello"})); + cell.mark_running(); + cell.add_output_line("Processing..."); + cell.add_output_line("Step 1 complete"); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +/// Test rendering of a completed exec cell. +#[test] +fn test_exec_cell_success() { + let mut cell = ExecCell::new( + "test-1", + "read_file", + serde_json::json!({"path": "test.rs"}), + ); + cell.mark_running(); + cell.mark_success("File content here\nMultiple lines\nOf text"); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +/// Test rendering of a failed exec cell. +#[test] +fn test_exec_cell_error() { + let mut cell = ExecCell::new( + "test-1", + "bash", + serde_json::json!({"cmd": "invalid_command"}), + ); + cell.mark_running(); + cell.mark_error("Command not found: invalid_command"); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +/// Test rendering of expanded exec cell. +#[test] +fn test_exec_cell_expanded() { + let mut cell = ExecCell::new( + "test-1", + "write_file", + serde_json::json!({ + "path": "output.txt", + "content": "Hello World" + }), + ); + cell.mark_running(); + cell.mark_success("File written successfully"); + cell.toggle_expanded(); + + let backend = TestBackend::new(80, 25); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +/// Test live output during execution. +#[test] +fn test_exec_cell_live_output() { + let mut cell = ExecCell::new( + "test-1", + "bash", + serde_json::json!({"cmd": "long_running_command"}), + ); + cell.mark_running(); + + // Add multiple output lines + cell.add_output_line("Starting process..."); + cell.add_output_line("Loading configuration"); + cell.add_output_line("Connecting to database"); + cell.add_output_line("Executing query"); + cell.add_output_line("Processing results"); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + ExecCellWidget::render(&cell, area, f.buffer_mut()); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} From 7eec66f727b3c1b85674d7cae9294c7abce9242e Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 4 Feb 2026 05:26:28 -0600 Subject: [PATCH 2/2] test: update insta snapshots for timing differences --- codi-rs/tests/exec_cell_unit.rs | 4 ++-- .../tests/snapshots/tui_exec_cell__exec_cell_live_output.snap | 2 +- codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap | 2 +- codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codi-rs/tests/exec_cell_unit.rs b/codi-rs/tests/exec_cell_unit.rs index aebe5b1..a89f54c 100644 --- a/codi-rs/tests/exec_cell_unit.rs +++ b/codi-rs/tests/exec_cell_unit.rs @@ -208,8 +208,8 @@ fn test_exec_cell_spinner_animation() { assert_ne!(frame1, frame2); assert_ne!(char1, char2); - // Should cycle back - for _ in 0..10 { + // Should cycle back (SPINNER_CHARS has 10 elements, so 9 more ticks to complete cycle) + for _ in 0..9 { cell.tick_spinner(); } assert_eq!(cell.spinner_frame, frame1); diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap index 88a6dc8..080678b 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap @@ -4,7 +4,7 @@ assertion_line: 157 expression: terminal.backend() --- "┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (1ms) │" +"│ ⠋ Running... (3ms) │" "│ Input: {"cmd":"long_running_command"} │" "│ ┌──────────────────────────────────────────────────────────────────────────┐ │" "│ │ Starting process... │ │" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap index 0f1f633..6a74db2 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap @@ -4,7 +4,7 @@ assertion_line: 30 expression: terminal.backend() --- "┌ read_file ───────────────────────────────────────────────────────────────────┐" -"│ ○ Running... (1ms) │" +"│ ○ Running... (0ms) │" "│ Input: {"path":"test.rs"} │" "│ │" "│ │" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap index a4579ba..e94b93d 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap @@ -4,7 +4,7 @@ assertion_line: 51 expression: terminal.backend() --- "┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (2ms) │" +"│ ⠋ Running... (1ms) │" "│ Input: {"cmd":"echo hello"} │" "│ ┌──────────────────────────────────────────────────────────────────────────┐ │" "│ │ Processing... │ │"