diff --git a/codi-rs/Cargo.toml b/codi-rs/Cargo.toml index f5b49a7..228cd5c 100644 --- a/codi-rs/Cargo.toml +++ b/codi-rs/Cargo.toml @@ -88,6 +88,8 @@ tree-sitter-rust = "0.23" tree-sitter-python = "0.23" tree-sitter-go = "0.23" tree-sitter-json = "0.24" +tree-sitter-bash = "0.25" +tree-sitter-markdown = "0.7" sha2 = "0.10" # MCP Protocol (Phase 6.5) @@ -98,6 +100,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 ed123c2..ffbfb49 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -293,7 +293,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.input); } // Execute the tool @@ -326,7 +326,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, &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 1f4c27a..2989ed8 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, StreamEvent}; use crate::tools::ToolRegistry; +use crate::types::{BoxedProvider, Message, StreamEvent}; /// Statistics for a single turn (user message -> final response). #[derive(Debug, Clone, Default)] @@ -69,9 +69,9 @@ pub enum ConfirmationResult { pub struct AgentCallbacks { /// Called when the model outputs text (streaming deltas). pub on_text: Option>, - /// Called when a tool is about to be executed. - pub on_tool_call: Option>, - /// Called when a tool execution completes. + /// Called when a tool is about to be executed (tool_id, tool_name, input). + pub on_tool_call: Option>, + /// Called when a tool execution completes (tool_id, result, is_error). pub on_tool_result: Option>, /// Called to confirm destructive operations. Returns approval result. pub on_confirm: Option ConfirmationResult + Send + Sync>>, diff --git a/codi-rs/src/orchestrate/child_agent.rs b/codi-rs/src/orchestrate/child_agent.rs index 9af53c0..caddbba 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(Arc::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 30b5908..4109a54 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -144,9 +144,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), @@ -219,6 +221,10 @@ pub struct App { /// Receiver for agent returning from a background chat task. pending_agent: Option)>>, + // Tool execution visualization + /// Manager for tool execution cells. + pub exec_cells: crate::tui::components::ExecCellManager, + // Orchestration /// Commander for multi-agent orchestration. commander: Option, @@ -266,6 +272,7 @@ impl App { config: None, auto_approve_all: false, pending_agent: None, + exec_cells: crate::tui::components::ExecCellManager::new(), commander: None, pending_worker_permissions: Vec::new(), } @@ -343,14 +350,14 @@ impl App { })), on_tool_call: Some(Arc::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(Arc::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, 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 @@ -459,14 +466,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/diff_view.rs b/codi-rs/src/tui/components/diff_view.rs new file mode 100644 index 0000000..c1fec95 --- /dev/null +++ b/codi-rs/src/tui/components/diff_view.rs @@ -0,0 +1,482 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Diff view component for rendering unified diffs in the TUI. +//! +//! This component renders a unified diff with color coding: +//! - Green for added lines +//! - Red for removed lines +//! - Gray for context lines +//! +//! It handles scrolling for large diffs and displays line numbers. + +use ratatui::{ + backend::Backend, + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}, +}; + +use crate::tui::diff::{DiffLine, UnifiedDiff}; + +/// Scroll state for the diff view. +#[derive(Debug, Clone, Default)] +pub struct DiffViewState { + /// Vertical scroll offset. + pub scroll_offset: usize, + /// Whether the view is focused. + pub focused: bool, +} + +impl DiffViewState { + /// Create a new state. + pub fn new() -> Self { + Self::default() + } + + /// Scroll up by n lines. + pub fn scroll_up(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + } + + /// Scroll down by n lines. + pub fn scroll_down(&mut self, n: usize, max_scroll: usize) { + self.scroll_offset = (self.scroll_offset + n).min(max_scroll); + } + + /// Scroll to the top. + pub fn scroll_to_top(&mut self) { + self.scroll_offset = 0; + } + + /// Scroll to the bottom. + pub fn scroll_to_bottom(&mut self, max_scroll: usize) { + self.scroll_offset = max_scroll; + } +} + +/// Configuration for the diff view appearance. +#[derive(Debug, Clone)] +pub struct DiffViewConfig { + /// Style for added lines (default: green). + pub added_style: Style, + /// Style for removed lines (default: red). + pub removed_style: Style, + /// Style for context lines (default: gray). + pub context_style: Style, + /// Style for line numbers (default: dark gray). + pub line_number_style: Style, + /// Style for the header (default: cyan). + pub header_style: Style, + /// Style for hunk headers (default: yellow). + pub hunk_header_style: Style, + /// Whether to show line numbers. + pub show_line_numbers: bool, + /// Width of the line number column. + pub line_number_width: u16, + /// Whether to use a block border. + pub show_border: bool, +} + +impl Default for DiffViewConfig { + fn default() -> Self { + Self { + added_style: Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + removed_style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + context_style: Style::default().fg(Color::Gray), + line_number_style: Style::default().fg(Color::DarkGray), + header_style: Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + hunk_header_style: Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + show_line_numbers: true, + line_number_width: 6, + show_border: true, + } + } +} + +/// A widget for rendering unified diffs. +#[derive(Debug, Clone)] +pub struct DiffView<'a> { + diff: &'a UnifiedDiff, + config: DiffViewConfig, + block: Option>, +} + +impl<'a> DiffView<'a> { + /// Create a new diff view with default configuration. + pub fn new(diff: &'a UnifiedDiff) -> Self { + Self { + diff, + config: DiffViewConfig::default(), + block: None, + } + } + + /// Create a new diff view with custom configuration. + pub fn with_config(diff: &'a UnifiedDiff, config: DiffViewConfig) -> Self { + Self { + diff, + config, + block: None, + } + } + + /// Set the block (border) for the diff view. + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + + /// Disable line numbers. + pub fn hide_line_numbers(mut self) -> Self { + self.config.show_line_numbers = false; + self + } + + /// Set whether to show the border. + pub fn show_border(mut self, show: bool) -> Self { + self.config.show_border = show; + self + } + + /// Calculate the total number of lines in the rendered diff. + pub fn total_lines(&self) -> usize { + let mut count = 0; + + // Header lines + count += 2; // --- and +++ lines + + // Hunk lines + for hunk in &self.diff.hunks { + count += 1; // Hunk header + count += hunk.lines.len(); + } + + count + } + + /// Render the diff into a vector of styled lines. + fn render_lines(&self) -> Vec> { + let mut lines = Vec::new(); + let cfg = &self.config; + + // Header lines + lines.push(Line::from(vec![ + Span::styled("--- ", cfg.header_style), + Span::styled(self.diff.old_file.clone(), cfg.context_style), + ])); + lines.push(Line::from(vec![ + Span::styled("+++ ", cfg.header_style), + Span::styled(self.diff.new_file.clone(), cfg.context_style), + ])); + + // Render each hunk + for hunk in &self.diff.hunks { + // Hunk header: @@ -old_start,old_lines +new_start,new_lines @@ + let header_text = format!( + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + ); + lines.push(Line::from(Span::styled(header_text, cfg.hunk_header_style))); + + // Track line numbers for display + let mut old_line = hunk.old_start; + let mut new_line = hunk.new_start; + + // Render each line in the hunk + for line in &hunk.lines { + let (prefix, content, style, old_num, new_num) = match line { + DiffLine::Context(text) => { + let num = old_line; + old_line += 1; + new_line += 1; + ( + ' ', + text.as_str(), + cfg.context_style, + Some(num), + Some(new_line - 1), + ) + } + DiffLine::Added(text) => { + let num = new_line; + new_line += 1; + ('+', text.as_str(), cfg.added_style, None, Some(num)) + } + DiffLine::Removed(text) => { + let num = old_line; + old_line += 1; + ('-', text.as_str(), cfg.removed_style, Some(num), None) + } + }; + + let line_content = if cfg.show_line_numbers { + // Format: " old | new | content" + let old_str = old_num + .map(|n| { + format!("{:>width$}", n, width = cfg.line_number_width as usize - 1) + }) + .unwrap_or_else(|| " ".repeat(cfg.line_number_width as usize - 1)); + let new_str = new_num + .map(|n| { + format!("{:>width$}", n, width = cfg.line_number_width as usize - 1) + }) + .unwrap_or_else(|| " ".repeat(cfg.line_number_width as usize - 1)); + + vec![ + Span::styled(format!("{} ", old_str), cfg.line_number_style), + Span::styled(format!("{} ", new_str), cfg.line_number_style), + Span::styled(format!("{} ", prefix), style), + Span::styled(content.to_string(), style), + ] + } else { + vec![ + Span::styled(format!("{} ", prefix), style), + Span::styled(content.to_string(), style), + ] + }; + + lines.push(Line::from(line_content)); + } + } + + lines + } +} + +impl<'a> StatefulWidget for DiffView<'a> { + type State = DiffViewState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let inner_area = if let Some(ref block) = self.block { + block.inner(area) + } else { + area + }; + + // Render block if present + if let Some(block) = self.block { + block.render(area, buf); + } + + // Calculate available space + let available_height = inner_area.height as usize; + let total_lines = self.total_lines(); + + // Calculate scroll offset + let max_scroll = total_lines.saturating_sub(available_height); + let scroll = state.scroll_offset.min(max_scroll); + + // Render lines + let lines = self.render_lines(); + let visible_lines: Vec = lines + .into_iter() + .skip(scroll) + .take(available_height) + .collect(); + + // Create paragraph and render + let paragraph = Paragraph::new(visible_lines); + paragraph.render(inner_area, buf); + + // Update state + state.scroll_offset = scroll; + } +} + +impl<'a> Widget for DiffView<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut state = DiffViewState::default(); + StatefulWidget::render(self, area, buf, &mut state); + } +} + +/// A convenience function to create a diff view with statistics header. +pub fn diff_view_with_stats<'a>(diff: &'a UnifiedDiff) -> DiffView<'a> { + DiffView::new(diff) +} + +/// Calculate the optimal size for a diff view. +pub fn calculate_diff_size(diff: &UnifiedDiff, max_width: u16, max_height: u16) -> (u16, u16) { + let config = DiffViewConfig::default(); + + // Calculate width based on content + let mut max_content_width = 0usize; + + // Check header lines + max_content_width = max_content_width.max(diff.old_file.len() + 4); + max_content_width = max_content_width.max(diff.new_file.len() + 4); + + // Check hunk lines + for hunk in &diff.hunks { + // Hunk header + let header_len = format!( + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + ) + .len(); + max_content_width = max_content_width.max(header_len); + + // Content lines + for line in &hunk.lines { + let content_len = line.content().len(); + let total_len = if config.show_line_numbers { + (config.line_number_width as usize * 2) + 3 + content_len + } else { + 2 + content_len + }; + max_content_width = max_content_width.max(total_len); + } + } + + let width = ((max_content_width + 2) as u16).min(max_width).max(40); + + // Calculate height based on content + let total_lines = 2 + diff.hunks.iter().map(|h| 1 + h.lines.len()).sum::(); + let height = ((total_lines + 2) as u16).min(max_height).max(10); + + (width, height) +} + +/// Create a compact diff view for embedding in small spaces. +pub fn compact_diff_view<'a>(diff: &'a UnifiedDiff) -> DiffView<'a> { + let config = DiffViewConfig { + show_line_numbers: false, + show_border: false, + ..Default::default() + }; + + DiffView::with_config(diff, config) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::diff::generate_unified_diff; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn create_test_terminal(width: u16, height: u16) -> Terminal { + let backend = TestBackend::new(width, height); + Terminal::new(backend).unwrap() + } + + #[test] + fn test_diff_view_render() { + let old = "line1\nline2\nline3"; + let new = "line1\nmodified\nline3"; + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + let view = DiffView::new(&diff); + assert!(view.total_lines() > 0); + } + + #[test] + fn test_diff_view_state_scrolling() { + let mut state = DiffViewState::new(); + + assert_eq!(state.scroll_offset, 0); + + state.scroll_down(5, 100); + assert_eq!(state.scroll_offset, 5); + + state.scroll_down(100, 10); + assert_eq!(state.scroll_offset, 10); // Should be clamped + + state.scroll_up(3); + assert_eq!(state.scroll_offset, 7); + + state.scroll_to_top(); + assert_eq!(state.scroll_offset, 0); + + state.scroll_to_bottom(50); + assert_eq!(state.scroll_offset, 50); + } + + #[test] + fn test_diff_view_widget_render() { + let old = "foo\nbar\nbaz"; + let new = "foo\nqux\nbaz"; + let diff = generate_unified_diff(Some(old), new, Some("file.txt"), 3); + + let mut terminal = create_test_terminal(80, 24); + let view = DiffView::new(&diff); + + terminal + .draw(|f| { + f.render_widget(view, f.area()); + }) + .unwrap(); + + // Check that something was rendered (no panic) + let _buffer = terminal.backend(); + } + + #[test] + fn test_diff_view_with_border() { + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 2); + + let view = DiffView::new(&diff).block(Block::default().borders(Borders::ALL).title("Diff")); + + let mut terminal = create_test_terminal(80, 24); + terminal + .draw(|f| { + f.render_widget(view, f.area()); + }) + .unwrap(); + } + + #[test] + fn test_compact_diff_view() { + let old = "line1\nline2"; + let new = "line1\nmodified"; + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + let view = compact_diff_view(&diff); + assert!(!view.config.show_line_numbers); + assert!(!view.config.show_border); + } + + #[test] + fn test_calculate_diff_size() { + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + let (width, height) = calculate_diff_size(&diff, 100, 50); + + assert!(width > 0); + assert!(width <= 100); + assert!(height > 0); + assert!(height <= 50); + } + + #[test] + fn test_stateful_widget_render() { + let old = "foo\nbar"; + let new = "foo\nbaz"; + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + let mut terminal = create_test_terminal(80, 24); + let view = DiffView::new(&diff); + let mut state = DiffViewState::new(); + + terminal + .draw(|f| { + StatefulWidget::render(view, f.area(), f.buffer_mut(), &mut state); + }) + .unwrap(); + + assert_eq!(state.scroll_offset, 0); + } +} 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/diff.rs b/codi-rs/src/tui/diff.rs new file mode 100644 index 0000000..c005ca2 --- /dev/null +++ b/codi-rs/src/tui/diff.rs @@ -0,0 +1,586 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Diff generation and parsing for unified diff display. +//! +//! This module provides utilities for generating and parsing unified diffs +//! similar to `git diff` output. + +use std::fmt::Write; + +/// A line in a diff hunk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffLine { + /// Context line (unchanged). + Context(String), + /// Added line (starts with +). + Added(String), + /// Removed line (starts with -). + Removed(String), +} + +impl DiffLine { + /// Get the content of the line (without the +/- prefix). + pub fn content(&self) -> &str { + match self { + DiffLine::Context(s) | DiffLine::Added(s) | DiffLine::Removed(s) => s.as_str(), + } + } + + /// Get the line prefix character. + pub fn prefix(&self) -> char { + match self { + DiffLine::Context(_) => ' ', + DiffLine::Added(_) => '+', + DiffLine::Removed(_) => '-', + } + } +} + +/// A hunk in a diff (a section of changes). +#[derive(Debug, Clone)] +pub struct DiffHunk { + /// Old file starting line number. + pub old_start: usize, + /// Number of lines in old file for this hunk. + pub old_lines: usize, + /// New file starting line number. + pub new_start: usize, + /// Number of lines in new file for this hunk. + pub new_lines: usize, + /// The lines in this hunk. + pub lines: Vec, +} + +/// A parsed unified diff. +#[derive(Debug, Clone)] +pub struct UnifiedDiff { + /// File path (if available). + pub file_path: Option, + /// Old file content description. + pub old_file: String, + /// New file content description. + pub new_file: String, + /// The hunks of changes. + pub hunks: Vec, + /// Total lines added. + pub lines_added: usize, + /// Total lines removed. + pub lines_removed: usize, + /// Whether this is a new file. + pub is_new_file: bool, +} + +/// Generate a unified diff between two strings. +/// +/// # Arguments +/// * `old_content` - The original content (None for new files) +/// * `new_content` - The new content +/// * `file_path` - Optional file path for display +/// * `context_lines` - Number of context lines to include (default: 3) +/// +/// # Example +/// ``` +/// use codi::tui::diff::generate_unified_diff; +/// +/// let old = "line1\nline2\nline3"; +/// let new = "line1\nmodified\nline3"; +/// let diff = generate_unified_diff(Some(old), new, Some("file.txt"), 3); +/// +/// assert!(diff.hunks.len() > 0); +/// assert_eq!(diff.file_path, Some("file.txt".to_string())); +/// ``` +pub fn generate_unified_diff( + old_content: Option<&str>, + new_content: &str, + file_path: Option<&str>, + context_lines: usize, +) -> UnifiedDiff { + let old_content = old_content.unwrap_or(""); + let is_new_file = old_content.is_empty() && !new_content.is_empty(); + + let old_lines: Vec<&str> = old_content.lines().collect(); + let new_lines: Vec<&str> = new_content.lines().collect(); + + // Compute LCS-based diff + let changes = compute_diff(&old_lines, &new_lines); + + // Group changes into hunks with context + let hunks = create_hunks(&changes, &old_lines, &new_lines, context_lines); + + // Count statistics + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for change in &changes { + match change { + Change::Add(_) => lines_added += 1, + Change::Delete(_) => lines_removed += 1, + _ => {} + } + } + + UnifiedDiff { + file_path: file_path.map(|s| s.to_string()), + old_file: if is_new_file { + "/dev/null".to_string() + } else { + format!("a/{}", file_path.unwrap_or("file")) + }, + new_file: format!("b/{}", file_path.unwrap_or("file")), + hunks, + lines_added, + lines_removed, + is_new_file, + } +} + +/// A change operation from the diff algorithm. +#[derive(Debug, Clone)] +enum Change { + /// Line kept from old (with index). + Keep(usize), + /// Line deleted from old (with index). + Delete(usize), + /// Line added from new (with index). + Add(usize), +} + +/// Compute the diff between two sequences using a simple LCS algorithm. +fn compute_diff(old: &[&str], new: &[&str]) -> Vec { + let m = old.len(); + let n = new.len(); + + // Use dynamic programming for LCS + // dp[i][j] = length of LCS of old[0..i] and new[0..j] + let mut dp = vec![vec![0usize; n + 1]; m + 1]; + + for i in (0..m).rev() { + for j in (0..n).rev() { + if old[i] == new[j] { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = dp[i][j + 1].max(dp[i + 1][j]); + } + } + } + + // Backtrack to find changes + let mut changes = Vec::new(); + let mut i = 0usize; + let mut j = 0usize; + + while i < m || j < n { + if i < m && j < n && old[i] == new[j] { + changes.push(Change::Keep(i)); + i += 1; + j += 1; + } else if j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j]) { + changes.push(Change::Add(j)); + j += 1; + } else if i < m { + changes.push(Change::Delete(i)); + i += 1; + } else { + changes.push(Change::Add(j)); + j += 1; + } + } + + changes +} + +/// Create hunks from changes with context lines. +fn create_hunks( + changes: &[Change], + old_lines: &[&str], + new_lines: &[&str], + context_lines: usize, +) -> Vec { + let mut hunks = Vec::new(); + let mut current_hunk: Option<(usize, usize, Vec)> = None; + + let mut old_line_num = 1usize; + let mut new_line_num = 1usize; + let mut last_change_end = 0usize; + + for (idx, change) in changes.iter().enumerate() { + let is_change = matches!(change, Change::Add(_) | Change::Delete(_)); + + if is_change { + // Check if we need to start a new hunk + let gap = idx.saturating_sub(last_change_end); + let needs_new_hunk = current_hunk.is_none() || gap > context_lines * 2; + + if needs_new_hunk { + // Finish current hunk if exists + if let Some((old_start, new_start, lines)) = current_hunk.take() { + hunks.push(DiffHunk { + old_start, + old_lines: old_line_num.saturating_sub(old_start), + new_start, + new_lines: new_line_num.saturating_sub(new_start), + lines, + }); + } + + // Start new hunk with context lines + let context_start = idx.saturating_sub(context_lines); + let old_start = old_line_num.saturating_sub(idx - context_start); + let new_start = new_line_num.saturating_sub(idx - context_start); + let mut lines = Vec::new(); + + // Add leading context + for ctx_idx in context_start..idx { + if let Change::Keep(i) = &changes[ctx_idx] { + lines.push(DiffLine::Context(old_lines[*i].to_string())); + } + } + + current_hunk = Some((old_start, new_start, lines)); + } else { + // Add gap context lines + for ctx_idx in last_change_end..idx { + if let Change::Keep(i) = &changes[ctx_idx] { + if let Some((_, _, ref mut lines)) = current_hunk { + lines.push(DiffLine::Context(old_lines[*i].to_string())); + } + } + } + } + + // Add the change + if let Some((_, _, ref mut lines)) = current_hunk { + match change { + Change::Delete(i) => { + lines.push(DiffLine::Removed(old_lines[*i].to_string())); + } + Change::Add(i) => { + lines.push(DiffLine::Added(new_lines[*i].to_string())); + } + _ => {} + } + } + + last_change_end = idx + 1; + } + + // Update line counters + match change { + Change::Keep(_) | Change::Delete(_) => old_line_num += 1, + _ => {} + } + match change { + Change::Keep(_) | Change::Add(_) => new_line_num += 1, + _ => {} + } + } + + // Add trailing context to last hunk + if let Some((old_start, new_start, ref mut lines)) = current_hunk { + let end = (last_change_end + context_lines).min(changes.len()); + for ctx_idx in last_change_end..end { + if let Change::Keep(i) = &changes[ctx_idx] { + lines.push(DiffLine::Context(old_lines[*i].to_string())); + } + } + + hunks.push(DiffHunk { + old_start, + old_lines: old_line_num.saturating_sub(old_start), + new_start, + new_lines: new_line_num.saturating_sub(new_start), + lines, + }); + } + + hunks +} + +/// Render a unified diff as a string (git diff format). +pub fn render_diff_to_string(diff: &UnifiedDiff) -> String { + let mut output = String::new(); + + // Header + writeln!(output, "--- {}", diff.old_file).unwrap(); + writeln!(output, "+++ {}", diff.new_file).unwrap(); + + // Hunks + for hunk in &diff.hunks { + writeln!( + output, + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + ) + .unwrap(); + + for line in &hunk.lines { + match line { + DiffLine::Context(s) => writeln!(output, " {}", s).unwrap(), + DiffLine::Added(s) => writeln!(output, "+{}", s).unwrap(), + DiffLine::Removed(s) => writeln!(output, "-{}", s).unwrap(), + } + } + } + + output +} + +/// Parse a unified diff from a string. +/// +/// This is a simple parser that handles the format generated by +/// `generate_unified_diff` and standard git diff output. +pub fn parse_unified_diff(input: &str, file_path: Option<&str>) -> UnifiedDiff { + let mut hunks = Vec::new(); + let mut old_file = String::new(); + let mut new_file = String::new(); + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + let mut is_new_file = false; + + let mut current_hunk: Option = None; + + for line in input.lines() { + if line.starts_with("--- ") { + old_file = line[4..].to_string(); + is_new_file = old_file == "/dev/null"; + } else if line.starts_with("+++ ") { + new_file = line[4..].to_string(); + } else if line.starts_with("@@") { + // Save previous hunk if exists + if let Some(hunk) = current_hunk.take() { + hunks.push(hunk); + } + + // Parse hunk header: @@ -old_start,old_lines +new_start,new_lines @@ + if let Some(end) = line.find(" @@") { + let header = &line[3..end]; + let parts: Vec<&str> = header.split_whitespace().collect(); + if parts.len() == 2 { + let old_part = parts[0].trim_start_matches('-'); + let new_part = parts[1].trim_start_matches('+'); + + let (old_start, old_lines) = parse_range(old_part); + let (new_start, new_lines) = parse_range(new_part); + + current_hunk = Some(DiffHunk { + old_start, + old_lines, + new_start, + new_lines, + lines: Vec::new(), + }); + } + } + } else if let Some(ref mut hunk) = current_hunk { + if let Some(content) = line.strip_prefix('+') { + hunk.lines.push(DiffLine::Added(content.to_string())); + lines_added += 1; + } else if let Some(content) = line.strip_prefix('-') { + hunk.lines.push(DiffLine::Removed(content.to_string())); + lines_removed += 1; + } else if let Some(content) = line.strip_prefix(' ') { + hunk.lines.push(DiffLine::Context(content.to_string())); + } else if !line.is_empty() { + // Treat unknown lines as context (handles missing leading space) + hunk.lines.push(DiffLine::Context(line.to_string())); + } + } + } + + // Don't forget the last hunk + if let Some(hunk) = current_hunk { + hunks.push(hunk); + } + + UnifiedDiff { + file_path: file_path.map(|s| s.to_string()), + old_file, + new_file, + hunks, + lines_added, + lines_removed, + is_new_file, + } +} + +/// Parse a range string like "1,5" or "1" into (start, count). +fn parse_range(s: &str) -> (usize, usize) { + if let Some(comma) = s.find(',') { + let start = s[..comma].parse().unwrap_or(1); + let count = s[comma + 1..].parse().unwrap_or(1); + (start, count) + } else { + (s.parse().unwrap_or(1), 1) + } +} + +/// Get summary statistics for a diff. +pub fn diff_stats(diff: &UnifiedDiff) -> String { + if diff.is_new_file { + format!("{} insertions(+)", diff.lines_added) + } else if diff.lines_added == 0 && diff.lines_removed == 0 { + "no changes".to_string() + } else { + format!( + "{} insertions(+), {} deletions(-)", + diff.lines_added, diff.lines_removed + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_simple_diff() { + let old = "line1\nline2\nline3"; + let new = "line1\nmodified\nline3"; + + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + assert_eq!(diff.file_path, Some("test.txt".to_string())); + assert!(!diff.is_new_file); + assert!(diff.lines_added > 0); + assert!(diff.lines_removed > 0); + assert!(!diff.hunks.is_empty()); + } + + #[test] + fn test_generate_new_file() { + let new = "line1\nline2\nline3"; + + let diff = generate_unified_diff(None, new, Some("test.txt"), 3); + + assert!(diff.is_new_file); + assert_eq!(diff.lines_added, 3); + assert_eq!(diff.lines_removed, 0); + } + + #[test] + fn test_diff_line_types() { + let context = DiffLine::Context("hello".to_string()); + let added = DiffLine::Added("world".to_string()); + let removed = DiffLine::Removed("foo".to_string()); + + assert_eq!(context.prefix(), ' '); + assert_eq!(added.prefix(), '+'); + assert_eq!(removed.prefix(), '-'); + + assert_eq!(context.content(), "hello"); + assert_eq!(added.content(), "world"); + assert_eq!(removed.content(), "foo"); + } + + #[test] + fn test_render_and_parse() { + let old = "foo\nbar\nbaz"; + let new = "foo\nqux\nbaz"; + + let diff = generate_unified_diff(Some(old), new, Some("file.txt"), 3); + let rendered = render_diff_to_string(&diff); + + // Should be parseable + let parsed = parse_unified_diff(&rendered, Some("file.txt")); + + assert_eq!(parsed.file_path, diff.file_path); + assert_eq!(parsed.lines_added, diff.lines_added); + assert_eq!(parsed.lines_removed, diff.lines_removed); + assert_eq!(parsed.hunks.len(), diff.hunks.len()); + } + + #[test] + fn test_diff_stats() { + let diff = UnifiedDiff { + file_path: Some("test.txt".to_string()), + old_file: "a/test.txt".to_string(), + new_file: "b/test.txt".to_string(), + hunks: vec![], + lines_added: 5, + lines_removed: 3, + is_new_file: false, + }; + + assert_eq!(diff_stats(&diff), "5 insertions(+), 3 deletions(-)"); + + let new_file_diff = UnifiedDiff { + file_path: Some("test.txt".to_string()), + old_file: "/dev/null".to_string(), + new_file: "b/test.txt".to_string(), + hunks: vec![], + lines_added: 10, + lines_removed: 0, + is_new_file: true, + }; + + assert_eq!(diff_stats(&new_file_diff), "10 insertions(+)"); + } + + #[test] + fn test_empty_diff() { + let old = "line1\nline2"; + let new = "line1\nline2"; + + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 3); + + assert_eq!(diff.lines_added, 0); + assert_eq!(diff.lines_removed, 0); + } + + #[test] + fn test_multiline_diff() { + let old = "a\nb\nc\nd\ne"; + let new = "a\nX\nc\nY\ne"; + + let diff = generate_unified_diff(Some(old), new, Some("test.txt"), 2); + + assert_eq!(diff.lines_added, 2); + assert_eq!(diff.lines_removed, 2); + } + + #[test] + fn test_parse_range() { + assert_eq!(parse_range("1,5"), (1, 5)); + assert_eq!(parse_range("10,20"), (10, 20)); + assert_eq!(parse_range("5"), (5, 1)); + assert_eq!(parse_range("invalid"), (1, 1)); + } + + #[test] + fn test_compute_diff_identical() { + let old: Vec<&str> = vec!["a", "b", "c"]; + let new: Vec<&str> = vec!["a", "b", "c"]; + + let changes = compute_diff(&old, &new); + + // All should be Keep + assert!(changes.iter().all(|c| matches!(c, Change::Keep(_)))); + assert_eq!(changes.len(), 3); + } + + #[test] + fn test_compute_diff_additions() { + let old: Vec<&str> = vec!["a", "c"]; + let new: Vec<&str> = vec!["a", "b", "c"]; + + let changes = compute_diff(&old, &new); + + assert_eq!(changes.len(), 3); + assert!(matches!(changes[0], Change::Keep(0))); + assert!(matches!(changes[1], Change::Add(1))); // 'b' added + assert!(matches!(changes[2], Change::Keep(1))); + } + + #[test] + fn test_compute_diff_deletions() { + let old: Vec<&str> = vec!["a", "b", "c"]; + let new: Vec<&str> = vec!["a", "c"]; + + let changes = compute_diff(&old, &new); + + assert_eq!(changes.len(), 3); + assert!(matches!(changes[0], Change::Keep(0))); + assert!(matches!(changes[1], Change::Delete(1))); // 'b' deleted + assert!(matches!(changes[2], Change::Keep(2))); + } +} diff --git a/codi-rs/src/tui/mod.rs b/codi-rs/src/tui/mod.rs index 44d55b4..d25f5df 100644 --- a/codi-rs/src/tui/mod.rs +++ b/codi-rs/src/tui/mod.rs @@ -37,13 +37,16 @@ pub mod app; pub mod commands; +pub mod components; pub mod events; pub mod streaming; +pub mod syntax; pub mod ui; pub use app::{App, AppMode, Message as ChatMessage, build_system_prompt_from_config}; pub use events::{Event, EventHandler}; pub use streaming::{MarkdownStreamCollector, StreamController, StreamState, StreamStatus}; +pub use syntax::{HighlightType, SupportedLanguage, SyntaxHighlighter, Theme}; use std::io; use crossterm::{ diff --git a/codi-rs/src/tui/syntax/highlighter.rs b/codi-rs/src/tui/syntax/highlighter.rs new file mode 100644 index 0000000..8d63dc8 --- /dev/null +++ b/codi-rs/src/tui/syntax/highlighter.rs @@ -0,0 +1,631 @@ +use std::collections::HashMap; + +use ratatui::{ + style::{Color, Style}, + text::Span, +}; +use tree_sitter::{Parser, Query, QueryCursor}; + +/// Supported languages for syntax highlighting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SupportedLanguage { + Rust, + TypeScript, + Python, + Go, + JavaScript, + Json, + Bash, + Markdown, +} + +impl SupportedLanguage { + /// Detect language from file extension or language identifier. + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "rs" => Some(SupportedLanguage::Rust), + "ts" | "tsx" => Some(SupportedLanguage::TypeScript), + "py" | "pyw" => Some(SupportedLanguage::Python), + "go" => Some(SupportedLanguage::Go), + "js" | "jsx" | "mjs" | "cjs" => Some(SupportedLanguage::JavaScript), + "json" => Some(SupportedLanguage::Json), + "sh" | "bash" | "zsh" | "fish" => Some(SupportedLanguage::Bash), + "md" | "markdown" => Some(SupportedLanguage::Markdown), + _ => None, + } + } + + /// Get the tree-sitter language parser. + pub fn tree_sitter_language(&self) -> tree_sitter::Language { + match self { + SupportedLanguage::Rust => tree_sitter_rust::LANGUAGE.into(), + SupportedLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + SupportedLanguage::Python => tree_sitter_python::LANGUAGE.into(), + SupportedLanguage::Go => tree_sitter_go::LANGUAGE.into(), + SupportedLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(), + SupportedLanguage::Json => tree_sitter_json::LANGUAGE.into(), + SupportedLanguage::Bash => tree_sitter_bash::LANGUAGE.into(), + SupportedLanguage::Markdown => tree_sitter_markdown::LANGUAGE.into(), + } + } + + /// Get highlight query for this language. + pub fn highlight_query(&self) -> &'static str { + match self { + SupportedLanguage::Rust => RUST_HIGHLIGHT_QUERY, + SupportedLanguage::TypeScript => TYPESCRIPT_HIGHLIGHT_QUERY, + SupportedLanguage::Python => PYTHON_HIGHLIGHT_QUERY, + SupportedLanguage::Go => GO_HIGHLIGHT_QUERY, + SupportedLanguage::JavaScript => JAVASCRIPT_HIGHLIGHT_QUERY, + SupportedLanguage::Json => JSON_HIGHLIGHT_QUERY, + SupportedLanguage::Bash => BASH_HIGHLIGHT_QUERY, + SupportedLanguage::Markdown => MARKDOWN_HIGHLIGHT_QUERY, + } + } +} + +/// Dark theme color palette (default). +#[derive(Debug, Clone)] +pub struct Theme { + pub foreground: Color, + pub background: Color, + pub comment: Color, + pub keyword: Color, + pub string: Color, + pub number: Color, + pub function: Color, + pub type_name: Color, + pub variable: Color, + pub operator: Color, + pub constant: Color, + pub attribute: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} + +impl Theme { + /// Dark theme (default). + pub fn dark() -> Self { + Self { + foreground: Color::White, + background: Color::Black, + comment: Color::DarkGray, + keyword: Color::Magenta, + string: Color::Green, + number: Color::Yellow, + function: Color::Blue, + type_name: Color::Cyan, + variable: Color::White, + operator: Color::Red, + constant: Color::Yellow, + attribute: Color::LightCyan, + } + } + + /// Apply style based on highlight type. + pub fn style_for(&self, highlight_type: HighlightType) -> Style { + let color = match highlight_type { + HighlightType::Comment => self.comment, + HighlightType::Keyword => self.keyword, + HighlightType::String => self.string, + HighlightType::Number => self.number, + HighlightType::Function => self.function, + HighlightType::Type => self.type_name, + HighlightType::Variable => self.variable, + HighlightType::Operator => self.operator, + HighlightType::Constant => self.constant, + HighlightType::Attribute => self.attribute, + HighlightType::None => self.foreground, + }; + Style::default().fg(color) + } +} + +/// Types of syntax highlights. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightType { + None, + Comment, + Keyword, + String, + Number, + Function, + Type, + Variable, + Operator, + Constant, + Attribute, +} + +/// Syntax highlighter using tree-sitter. +pub struct SyntaxHighlighter { + theme: Theme, + parsers: HashMap, + queries: HashMap, +} + +impl SyntaxHighlighter { + /// Create a new syntax highlighter with default dark theme. + pub fn new() -> Self { + Self { + theme: Theme::default(), + parsers: HashMap::new(), + queries: HashMap::new(), + } + } + + /// Create with custom theme. + pub fn with_theme(theme: Theme) -> Self { + Self { + theme, + parsers: HashMap::new(), + queries: HashMap::new(), + } + } + + /// Get or create parser for language. + fn get_parser(&mut self, lang: SupportedLanguage) -> Option<&mut Parser> { + if !self.parsers.contains_key(&lang) { + let mut parser = Parser::new(); + let ts_lang = lang.tree_sitter_language(); + parser.set_language(&ts_lang).ok()?; + self.parsers.insert(lang, parser); + } + self.parsers.get_mut(&lang) + } + + /// Get or create query for language. + fn get_query(&mut self, lang: SupportedLanguage) -> Option<&Query> { + if !self.queries.contains_key(&lang) { + let ts_lang = lang.tree_sitter_language(); + let query = Query::new(&ts_lang, lang.highlight_query()).ok()?; + self.queries.insert(lang, query); + } + self.queries.get(&lang) + } + + /// Highlight code and return styled spans. + pub fn highlight(&mut self, code: &str, lang: SupportedLanguage) -> Vec>> { + let parser = match self.get_parser(lang) { + Some(p) => p, + None => return Self::plain_text_lines(code), + }; + + let tree = match parser.parse(code, None) { + Some(t) => t, + None => return Self::plain_text_lines(code), + }; + + let query = match self.get_query(lang) { + Some(q) => q, + None => return Self::plain_text_lines(code), + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, tree.root_node(), code.as_bytes()); + + let mut highlights: HashMap = HashMap::new(); + + while let Some(m) = matches.next() { + for capture in m.captures { + let capture_name = query.capture_names()[capture.index as usize]; + let highlight_type = Self::capture_to_highlight(capture_name); + + let node = capture.node; + for i in node.start_byte()..node.end_byte() { + if Self::is_more_specific( + highlight_type, + *highlights.get(&i).unwrap_or(&HighlightType::None), + ) { + highlights.insert(i, highlight_type); + } + } + } + } + + let mut lines: Vec> = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut line_start = 0; + + for (i, c) in code.char_indices() { + if c == '\n' { + if i > line_start { + current_line.push(self.span_for_range( + &code[line_start..i], + &highlights, + line_start, + )); + } + lines.push(current_line); + current_line = Vec::new(); + line_start = i + 1; + } + } + + if line_start < code.len() { + current_line.push(self.span_for_range(&code[line_start..], &highlights, line_start)); + } + if !current_line.is_empty() { + lines.push(current_line); + } + + lines + } + + /// Convert plain text to unstyled lines. + fn plain_text_lines(code: &str) -> Vec>> { + code.lines() + .map(|line| vec![Span::raw(line.to_string())]) + .collect() + } + + /// Create a span for a range of text with highlighting. + fn span_for_range( + &self, + text: &str, + highlights: &HashMap, + start_offset: usize, + ) -> Span<'static> { + let first_highlight = highlights + .get(&start_offset) + .copied() + .unwrap_or(HighlightType::None); + let all_same = (start_offset..start_offset + text.len()) + .all(|i| highlights.get(&i).copied().unwrap_or(HighlightType::None) == first_highlight); + + if all_same { + Span::styled(text.to_string(), self.theme.style_for(first_highlight)) + } else { + Span::raw(text.to_string()) + } + } + + /// Convert capture name to highlight type. + fn capture_to_highlight(name: &str) -> HighlightType { + match name { + "comment" => HighlightType::Comment, + "keyword" | "keyword.control" | "keyword.function" => HighlightType::Keyword, + "string" | "string.quoted" | "string.literal" => HighlightType::String, + "number" | "integer" | "float" => HighlightType::Number, + "function" | "function.call" | "function.method" => HighlightType::Function, + "type" | "type.builtin" | "type.definition" => HighlightType::Type, + "variable" | "variable.parameter" | "variable.other" => HighlightType::Variable, + "operator" | "operator.logical" | "operator.arithmetic" => HighlightType::Operator, + "constant" | "constant.builtin" | "constant.language" => HighlightType::Constant, + "attribute" | "decorator" | "annotation" => HighlightType::Attribute, + _ => HighlightType::None, + } + } + + /// Check if highlight a is more specific than b. + fn is_more_specific(a: HighlightType, b: HighlightType) -> bool { + let priority = |t: HighlightType| match t { + HighlightType::None => 0, + HighlightType::Variable => 1, + HighlightType::Constant => 2, + HighlightType::Attribute => 3, + HighlightType::Operator => 4, + HighlightType::Number => 5, + HighlightType::String => 6, + HighlightType::Type => 7, + HighlightType::Function => 8, + HighlightType::Keyword => 9, + HighlightType::Comment => 10, + }; + priority(a) > priority(b) + } + + /// Highlight code block and return as single vector of spans. + pub fn highlight_block(&mut self, code: &str, lang: SupportedLanguage) -> Vec> { + let lines = self.highlight(code, lang); + let mut result = Vec::new(); + + for (i, line) in lines.into_iter().enumerate() { + if i > 0 { + result.push(Span::raw("\n".to_string())); + } + result.extend(line); + } + + result + } +} + +impl Default for SyntaxHighlighter { + fn default() -> Self { + Self::new() + } +} + +// Tree-sitter highlight queries for each language +const RUST_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"fn" "struct" "enum" "impl" "trait" "type" "let" "mut" "const" "static" "pub" "use" "mod" "crate" "self" "Self" "super" "where" "if" "else" "match" "for" "while" "loop" "return" "break" "continue" "async" "await" "move" "ref" "Box" "Vec" "Option" "Result" @keyword + +; Types +(type_identifier) @type +(primitive_type) @type.builtin + +; Functions +(function_item name: (identifier) @function) +(call_expression function: (identifier) @function.call) +(call_expression function: (field_expression field: (field_identifier) @function.method)) + +; Variables +(identifier) @variable +(parameter (identifier) @variable.parameter) + +; Strings +(string_literal) @string +(raw_string_literal) @string +(char_literal) @string + +; Numbers +(integer_literal) @number +(float_literal) @number +(boolean_literal) @constant + +; Comments +(line_comment) @comment +(block_comment) @comment +(doc_comment) @comment + +; Attributes +(attribute_item) @attribute +"#; + +const TYPESCRIPT_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"const" "let" "var" "function" "class" "interface" "type" "enum" "namespace" "module" "import" "export" "from" "return" "if" "else" "for" "while" "switch" "case" "break" "continue" "try" "catch" "throw" "new" "this" "super" "extends" "implements" "public" "private" "protected" "static" "readonly" "abstract" "async" "await" "yield" @keyword + +; Types +(type_identifier) @type +(predefined_type) @type.builtin + +; Functions +(function_declaration name: (identifier) @function) +(method_definition name: (property_identifier) @function.method) +(call_expression function: (identifier) @function.call) + +; Variables +(identifier) @variable +(formal_parameters (identifier) @variable.parameter) + +; Strings +(string) @string +(template_string) @string + +; Numbers +(number) @number +(true) (false) @constant + +; Comments +(comment) @comment + +; Decorators +(decorator) @attribute +"#; + +const PYTHON_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"def" "class" "if" "elif" "else" "for" "while" "try" "except" "finally" "with" "as" "return" "yield" "raise" "break" "continue" "pass" "lambda" "global" "nonlocal" "assert" "del" "import" "from" "async" "await" @keyword + +; Functions +(function_definition name: (identifier) @function) +(call function: (identifier) @function.call) +(call function: (attribute attribute: (identifier) @function.method)) + +; Variables +(identifier) @variable +(parameters (identifier) @variable.parameter) + +; Strings +(string) @string +(escape_sequence) @string + +; Numbers +(integer) @number +(float) @number +(true) (false) (none) @constant + +; Comments +(comment) @comment + +; Decorators +(decorator) @attribute +"#; + +const GO_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"func" "type" "struct" "interface" "map" "chan" "const" "var" "import" "package" "return" "if" "else" "for" "range" "switch" "case" "default" "break" "continue" "goto" "defer" "go" "select" "fallthrough" @keyword + +; Types +(type_identifier) @type +(builtin_type) @type.builtin + +; Functions +(function_declaration name: (identifier) @function) +(method_declaration name: (field_identifier) @function.method) +(call_expression function: (identifier) @function.call) + +; Variables +(identifier) @variable +(parameter_declaration (identifier) @variable.parameter) + +; Strings +(raw_string_literal) @string +(interpreted_string_literal) @string +(rune_literal) @string + +; Numbers +(int_literal) @number +(float_literal) @number +(true) (false) @constant + +; Comments +(comment) @comment +"#; + +const JAVASCRIPT_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"const" "let" "var" "function" "class" "import" "export" "from" "return" "if" "else" "for" "while" "switch" "case" "break" "continue" "try" "catch" "throw" "new" "this" "super" "extends" "async" "await" "yield" @keyword + +; Functions +(function_declaration name: (identifier) @function) +(method_definition name: (property_identifier) @function.method) +(call_expression function: (identifier) @function.call) + +; Variables +(identifier) @variable +(formal_parameters (identifier) @variable.parameter) + +; Strings +(string) @string +(template_string) @string + +; Numbers +(number) @number +(true) (false) @constant + +; Comments +(comment) @comment +"#; + +const JSON_HIGHLIGHT_QUERY: &str = r#" +; Keys +(pair key: (string) @attribute) + +; Strings +(string) @string + +; Numbers +(number) @number + +; Constants +(true) (false) @constant +(null) @constant +"#; + +const BASH_HIGHLIGHT_QUERY: &str = r#" +; Keywords +"if" "then" "else" "elif" "fi" "for" "while" "do" "done" "case" "esac" "in" "function" "return" "break" "continue" "shift" "local" "export" "readonly" "unset" @keyword + +; Commands +(command name: (word) @function) +(function_definition name: (word) @function) + +; Strings +(string) @string +(raw_string) @string +(heredoc_body) @string + +; Variables +(variable_name) @variable +(expansion (variable_name) @variable) + +; Comments +(comment) @comment +"#; + +const MARKDOWN_HIGHLIGHT_QUERY: &str = r#" +; Headers +(atx_heading) @keyword +(setext_heading) @keyword + +; Code blocks +(fenced_code_block) @string +(indented_code_block) @string + +; Emphasis +(emphasis) @operator +(strong_emphasis) @operator + +; Links +(link_destination) @string +(link_text) @function + +; Lists +(list_item) @variable +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_language_from_extension() { + assert_eq!( + SupportedLanguage::from_extension("rs"), + Some(SupportedLanguage::Rust) + ); + assert_eq!( + SupportedLanguage::from_extension("ts"), + Some(SupportedLanguage::TypeScript) + ); + assert_eq!( + SupportedLanguage::from_extension("py"), + Some(SupportedLanguage::Python) + ); + assert_eq!( + SupportedLanguage::from_extension("go"), + Some(SupportedLanguage::Go) + ); + assert_eq!(SupportedLanguage::from_extension("unknown"), None); + } + + #[test] + fn test_theme_default_is_dark() { + let theme = Theme::default(); + assert_eq!(theme.foreground, Color::White); + assert_eq!(theme.background, Color::Black); + assert_eq!(theme.keyword, Color::Magenta); + } + + #[test] + fn test_highlight_type_priority() { + assert!(SyntaxHighlighter::is_more_specific( + HighlightType::Keyword, + HighlightType::None + )); + assert!(SyntaxHighlighter::is_more_specific( + HighlightType::Comment, + HighlightType::Keyword + )); + assert!(!SyntaxHighlighter::is_more_specific( + HighlightType::None, + HighlightType::Keyword + )); + } + + #[test] + fn test_capture_to_highlight() { + assert_eq!( + SyntaxHighlighter::capture_to_highlight("comment"), + HighlightType::Comment + ); + assert_eq!( + SyntaxHighlighter::capture_to_highlight("keyword"), + HighlightType::Keyword + ); + assert_eq!( + SyntaxHighlighter::capture_to_highlight("string"), + HighlightType::String + ); + assert_eq!( + SyntaxHighlighter::capture_to_highlight("unknown"), + HighlightType::None + ); + } + + #[test] + fn test_plain_text_lines() { + let code = "line1\nline2\nline3"; + let lines = SyntaxHighlighter::plain_text_lines(code); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0][0].content, "line1"); + } +} diff --git a/codi-rs/src/tui/syntax/mod.rs b/codi-rs/src/tui/syntax/mod.rs new file mode 100644 index 0000000..5302ab6 --- /dev/null +++ b/codi-rs/src/tui/syntax/mod.rs @@ -0,0 +1,11 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Syntax highlighting module for TUI. +//! +//! Provides tree-sitter based syntax highlighting for code blocks +//! with support for multiple languages and dark theme by default. + +pub mod highlighter; + +pub use highlighter::{HighlightType, SupportedLanguage, SyntaxHighlighter, Theme}; 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..a89f54c --- /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 (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); +} + +#[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..080678b --- /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... (3ms) │" +"│ 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..6a74db2 --- /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... (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 new file mode 100644 index 0000000..e94b93d --- /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... (1ms) │" +"│ 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()); +}