diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 84fc0bb..2532285 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -326,7 +326,12 @@ impl Agent { // Notify callback if let Some(ref on_tool_result) = self.callbacks.on_tool_result { - on_tool_result(&tool_call.id, &tool_call.name, &result.content, result.is_error.unwrap_or(false)); + on_tool_result( + &tool_call.id, + &tool_call.name, + &result.content, + result.is_error.unwrap_or(false), + ); } result diff --git a/codi-rs/src/tui/app.rs b/codi-rs/src/tui/app.rs index a004cb4..1504908 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -211,16 +211,13 @@ pub struct App { project_path: String, /// Tab completion hint to display. pub completion_hint: Option, - /// Resolved configuration from config files and CLI. config: Option, /// Auto-approve all tool operations (from --yes CLI flag). auto_approve_all: bool, - // Background agent task /// 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, diff --git a/codi-rs/src/tui/components/diff_view.rs b/codi-rs/src/tui/components/diff_view.rs index c1fec95..e1089de 100644 --- a/codi-rs/src/tui/components/diff_view.rs +++ b/codi-rs/src/tui/components/diff_view.rs @@ -11,12 +11,11 @@ //! It handles scrolling for large diffs and displays line numbers. use ratatui::{ - backend::Backend, buffer::Buffer, - layout::{Constraint, Direction, Layout, Rect}, + layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}, + widgets::{Block, Paragraph, StatefulWidget, Widget}, }; use crate::tui::diff::{DiffLine, UnifiedDiff}; @@ -220,13 +219,13 @@ impl<'a> DiffView<'a> { let line_content = if cfg.show_line_numbers { // Format: " old | new | content" - let old_str = old_num - .map(|n| { + let old_str: String = old_num + .map(|n: usize| { 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| { + let new_str: String = new_num + .map(|n: usize| { format!("{:>width$}", n, width = cfg.line_number_width as usize - 1) }) .unwrap_or_else(|| " ".repeat(cfg.line_number_width as usize - 1)); @@ -263,7 +262,7 @@ impl<'a> StatefulWidget for DiffView<'a> { }; // Render block if present - if let Some(block) = self.block { + if let Some(ref block) = self.block { block.render(area, buf); } @@ -327,7 +326,7 @@ pub fn calculate_diff_size(diff: &UnifiedDiff, max_width: u16, max_height: u16) // Content lines for line in &hunk.lines { - let content_len = line.content().len(); + let content_len: usize = line.content().len(); let total_len = if config.show_line_numbers { (config.line_number_width as usize * 2) + 3 + content_len } else { @@ -362,6 +361,7 @@ mod tests { use super::*; use crate::tui::diff::generate_unified_diff; use ratatui::backend::TestBackend; + use ratatui::widgets::Borders; use ratatui::Terminal; fn create_test_terminal(width: u16, height: u16) -> Terminal { diff --git a/codi-rs/src/tui/components/exec_cell.rs b/codi-rs/src/tui/components/exec_cell.rs index 14c5223..052f5c4 100644 --- a/codi-rs/src/tui/components/exec_cell.rs +++ b/codi-rs/src/tui/components/exec_cell.rs @@ -17,7 +17,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, StatefulWidget, Widget, Wrap}, + widgets::{Block, Borders, Paragraph, Widget, Wrap}, }; /// Status of a tool execution. @@ -195,7 +195,7 @@ impl ExecCell { } /// Calculate the height needed to render this cell. - pub fn required_height(&self, width: u16) -> u16 { + pub fn required_height(&self, _width: u16) -> u16 { let base_height = 3; // Header + border let input_height = if self.expanded { @@ -302,7 +302,7 @@ impl ExecCellWidget { let spinner = cell.spinner_char(); let duration = cell.format_duration(); - let header_text = if cell.status.is_terminal() { + let _header_text = if cell.status.is_terminal() { format!("{} {} ({})", spinner, cell.status_icon_text(), duration) } else { format!("{} Running... ({})", spinner, duration) diff --git a/codi-rs/src/tui/components/mod.rs b/codi-rs/src/tui/components/mod.rs index c89d79a..97c0e6c 100644 --- a/codi-rs/src/tui/components/mod.rs +++ b/codi-rs/src/tui/components/mod.rs @@ -5,9 +5,16 @@ //! //! This module provides reusable UI components for the Codi TUI. +pub mod diff_view; pub mod exec_cell; +pub mod process_footer; +pub mod search_bar; +pub use crate::tui::diff::DiffLine; +pub use diff_view::DiffView; pub use exec_cell::{ExecCell, ExecCellManager, ExecCellWidget, ToolStatus}; +pub use process_footer::{ProcessFooter, ProcessInfo}; +pub use search_bar::SearchBar; /// Snapshot testing utilities for TUI components. #[cfg(test)] diff --git a/codi-rs/src/tui/components/process_footer.rs b/codi-rs/src/tui/components/process_footer.rs new file mode 100644 index 0000000..58fa368 --- /dev/null +++ b/codi-rs/src/tui/components/process_footer.rs @@ -0,0 +1,239 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Process footer component for showing running tool executions. +//! +//! Displays a compact footer at the bottom of the TUI showing: +//! - Count of running vs completed processes +//! - Mini status indicators for each process +//! - Expandable detailed view + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Widget}, +}; + +use crate::tui::components::{ExecCell, ToolStatus}; + +/// Information about a running process for the footer. +#[derive(Debug, Clone)] +pub struct ProcessInfo { + /// Unique process ID. + pub id: String, + /// Process name (tool name). + pub name: String, + /// Current status. + pub status: ToolStatus, + /// Optional progress (0.0 - 1.0). + pub progress: Option, +} + +/// Footer showing all running processes. +pub struct ProcessFooter { + processes: Vec, +} + +impl ProcessFooter { + /// Create a new empty process footer. + pub fn new() -> Self { + Self { + processes: Vec::new(), + } + } + + /// Update with current exec cells. + pub fn from_exec_cells(cells: &[ExecCell]) -> Self { + let processes = cells + .iter() + .map(|cell| ProcessInfo { + id: cell.id.clone(), + name: cell.tool_name.clone(), + status: cell.status, + progress: None, // Could be calculated from output lines + }) + .collect(); + + Self { processes } + } + + /// Get running count. + pub fn running_count(&self) -> usize { + self.processes + .iter() + .filter(|p| p.status == ToolStatus::Running) + .count() + } + + /// Get completed count. + pub fn completed_count(&self) -> usize { + self.processes + .iter() + .filter(|p| p.status == ToolStatus::Success || p.status == ToolStatus::Error) + .count() + } + + /// Check if there are any processes. + pub fn has_processes(&self) -> bool { + !self.processes.is_empty() + } + + /// Get status icon for a process. + fn status_icon(status: ToolStatus) -> char { + match status { + ToolStatus::Pending => '○', + ToolStatus::Running => '◐', + ToolStatus::Success => '✓', + ToolStatus::Error => '✗', + } + } + + /// Get status color. + fn status_color(status: ToolStatus) -> Color { + match status { + ToolStatus::Pending => Color::Gray, + ToolStatus::Running => Color::Yellow, + ToolStatus::Success => Color::Green, + ToolStatus::Error => Color::Red, + } + } +} + +impl Default for ProcessFooter { + fn default() -> Self { + Self::new() + } +} + +impl Widget for ProcessFooter { + fn render(self, area: Rect, buf: &mut Buffer) { + if !self.has_processes() { + return; + } + + let running = self.running_count(); + let completed = self.completed_count(); + let total = self.processes.len(); + + // Build status line + let mut status_spans = vec![ + Span::styled( + format!("⏳ {} running ", running), + Style::default().fg(Color::Yellow), + ), + Span::styled( + format!("✓ {} completed ", completed), + Style::default().fg(Color::Green), + ), + Span::styled( + format!("({} total)", total), + Style::default().fg(Color::Gray), + ), + ]; + + // Add mini process indicators + status_spans.push(Span::raw(" | ")); + + for (i, process) in self.processes.iter().take(5).enumerate() { + if i > 0 { + status_spans.push(Span::raw(" ")); + } + status_spans.push(Span::styled( + format!("{}", Self::status_icon(process.status)), + Style::default().fg(Self::status_color(process.status)), + )); + status_spans.push(Span::styled( + format!(" {}", process.name), + Style::default().fg(Color::White), + )); + } + + if self.processes.len() > 5 { + status_spans.push(Span::styled( + format!(" +{} more", self.processes.len() - 5), + Style::default().fg(Color::Gray), + )); + } + + // Render footer + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::Gray)); + + let inner = block.inner(area); + block.render(area, buf); + + let line = Line::from(status_spans); + buf.set_line(inner.x, inner.y, &line, inner.width); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + #[test] + fn test_process_footer_empty() { + let footer = ProcessFooter::new(); + assert!(!footer.has_processes()); + assert_eq!(footer.running_count(), 0); + assert_eq!(footer.completed_count(), 0); + } + + #[test] + fn test_process_footer_counts() { + let mut footer = ProcessFooter::new(); + footer.processes = vec![ + ProcessInfo { + id: "1".to_string(), + name: "bash".to_string(), + status: ToolStatus::Running, + progress: None, + }, + ProcessInfo { + id: "2".to_string(), + name: "read_file".to_string(), + status: ToolStatus::Success, + progress: None, + }, + ProcessInfo { + id: "3".to_string(), + name: "grep".to_string(), + status: ToolStatus::Error, + progress: None, + }, + ]; + + assert!(footer.has_processes()); + assert_eq!(footer.running_count(), 1); + assert_eq!(footer.completed_count(), 2); + } + + #[test] + fn test_process_footer_render() { + let mut footer = ProcessFooter::new(); + footer.processes = vec![ProcessInfo { + id: "1".to_string(), + name: "bash".to_string(), + status: ToolStatus::Running, + progress: None, + }]; + + let backend = TestBackend::new(80, 3); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + footer.render(area, f.buffer_mut()); + }) + .unwrap(); + + // Should render without panic + let buffer = terminal.backend().buffer().clone(); + assert!(buffer.content.len() > 0); + } +} diff --git a/codi-rs/src/tui/components/search_bar.rs b/codi-rs/src/tui/components/search_bar.rs new file mode 100644 index 0000000..e7449d3 --- /dev/null +++ b/codi-rs/src/tui/components/search_bar.rs @@ -0,0 +1,125 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Search bar UI component. + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +use crate::tui::search::SearchState; + +/// Search bar widget for incremental search. +pub struct SearchBar<'a> { + state: &'a SearchState, +} + +impl<'a> SearchBar<'a> { + /// Create a new search bar. + pub fn new(state: &'a SearchState) -> Self { + Self { state } + } +} + +impl<'a> Widget for SearchBar<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + // Build status text + let status = if self.state.query.is_empty() { + "Search...".to_string() + } else if self.state.has_results() { + format!( + "{}/{} matches | {} | {}", + self.state.current_index + 1, + self.state.result_count(), + if self.state.case_sensitive { + "Aa" + } else { + "aa" + }, + self.state.query + ) + } else { + format!( + "No matches | {} | {}", + if self.state.case_sensitive { + "Aa" + } else { + "aa" + }, + self.state.query + ) + }; + + let style = if self.state.has_results() || self.state.query.is_empty() { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Red) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(style) + .title(format!(" Search: {} ", status)); + + let paragraph = Paragraph::new("").block(block); + + paragraph.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + #[test] + fn test_search_bar_empty() { + let state = SearchState::new(); + let backend = TestBackend::new(80, 3); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + let bar = SearchBar::new(&state); + bar.render(area, f.buffer_mut()); + }) + .unwrap(); + + // Should render without panic + let buffer = terminal.backend().buffer().clone(); + assert!(buffer.content.len() > 0); + } + + #[test] + fn test_search_bar_with_results() { + let mut state = SearchState::new(); + state.query = "test".to_string(); + state.results.push(crate::tui::search::SearchResult { + message_id: "1".to_string(), + line_number: 0, + char_index: 0, + match_length: 4, + context: "test".to_string(), + }); + + let backend = TestBackend::new(80, 3); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let area = f.area(); + let bar = SearchBar::new(&state); + bar.render(area, f.buffer_mut()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer().clone(); + // Should show "1/1 matches" + let content: String = buffer.content.iter().map(|c| c.symbol()).collect(); + assert!(content.contains("1/1 matches")); + } +} diff --git a/codi-rs/src/tui/diff.rs b/codi-rs/src/tui/diff.rs index c005ca2..126e10f 100644 --- a/codi-rs/src/tui/diff.rs +++ b/codi-rs/src/tui/diff.rs @@ -289,7 +289,7 @@ fn create_hunks( old_lines: old_line_num.saturating_sub(old_start), new_start, new_lines: new_line_num.saturating_sub(new_start), - lines, + lines: lines.clone(), }); } diff --git a/codi-rs/src/tui/input/enhanced.rs b/codi-rs/src/tui/input/enhanced.rs index 0016c1c..c8ffa43 100644 --- a/codi-rs/src/tui/input/enhanced.rs +++ b/codi-rs/src/tui/input/enhanced.rs @@ -32,13 +32,11 @@ //! } //! ``` -use std::io::{self, Write}; +use std::io::{self}; use crossterm::{ - cursor::Show, event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, - terminal::{disable_raw_mode, enable_raw_mode}, }; /// Represents a key event with full modifier information. @@ -104,14 +102,27 @@ pub struct KeyModifiers { pub meta: bool, } +/// CSI u modifier encoding variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModifierEncoding { + /// CSI u bitmask (Shift=1, Alt=2, Ctrl=4, Meta=8). + Bitmask, + /// Xterm-style 1-based encoding (None=1, Shift=2, Alt=3, ...). + Xterm, +} + impl KeyModifiers { - /// Create from CSI u modifier bitmask. - fn from_csi_u_mask(mask: u8) -> Self { + fn from_csi_u_mask_with_encoding(mask: u8, encoding: ModifierEncoding) -> Self { + let effective = match encoding { + ModifierEncoding::Bitmask => mask, + ModifierEncoding::Xterm => mask.saturating_sub(1), + }; + Self { - shift: mask & 1 != 0, - alt: mask & 2 != 0, - ctrl: mask & 4 != 0, - meta: mask & 8 != 0, + shift: effective & 1 != 0, + alt: effective & 2 != 0, + ctrl: effective & 4 != 0, + meta: effective & 8 != 0, } } @@ -202,10 +213,18 @@ impl EnhancedInput { /// /// This handles both CSI u format and standard escape sequences. pub fn parse_key_sequence(data: &[u8]) -> Option { + Self::parse_key_sequence_with_encoding(data, ModifierEncoding::Bitmask) + } + + /// Parse a key sequence using a specific CSI u modifier encoding. + pub fn parse_key_sequence_with_encoding( + data: &[u8], + encoding: ModifierEncoding, + ) -> Option { let seq = String::from_utf8_lossy(data); // Try CSI u format first (ESC [ unicode ; modifiers u) - if let Some(event) = Self::parse_csi_u(&seq) { + if let Some(event) = Self::parse_csi_u(&seq, encoding) { return Some(event); } @@ -214,7 +233,7 @@ impl EnhancedInput { } /// Parse CSI u format: ESC [ unicode ; modifiers u - fn parse_csi_u(seq: &str) -> Option { + fn parse_csi_u(seq: &str, encoding: ModifierEncoding) -> Option { // CSI u pattern: ESC [ [ ; ] u let pattern = regex::Regex::new(r"^\x1b\[(\d+)(?:;(\d+))?u$").ok()?; @@ -241,7 +260,7 @@ impl EnhancedInput { return Some(KeyEvent { code, - modifiers: KeyModifiers::from_csi_u_mask(modifiers), + modifiers: KeyModifiers::from_csi_u_mask_with_encoding(modifiers, encoding), raw_sequence: seq.to_string(), }); } @@ -324,8 +343,15 @@ pub fn detect_terminal_capabilities() -> TerminalCapabilities { || termini.to_lowercase().contains(t) }); + let modifier_encoding = if supports_csi_u { + ModifierEncoding::Bitmask + } else { + ModifierEncoding::Xterm + }; + TerminalCapabilities { supports_csi_u, + modifier_encoding, term: term.clone(), term_program, } @@ -336,6 +362,8 @@ pub fn detect_terminal_capabilities() -> TerminalCapabilities { pub struct TerminalCapabilities { /// Terminal supports CSI u protocol. pub supports_csi_u: bool, + /// CSI u modifier encoding. + pub modifier_encoding: ModifierEncoding, /// TERM environment variable. pub term: String, /// TERM_PROGRAM environment variable. @@ -362,12 +390,19 @@ impl SmartInput { /// This enables enhanced keys if the terminal supports it, /// otherwise falls back to standard input. pub fn init(&mut self) -> io::Result<()> { - if self.capabilities.supports_csi_u { + let enabled = self.enhanced.enable_enhanced_keys()?; + self.capabilities.supports_csi_u = enabled; + self.capabilities.modifier_encoding = if enabled { + ModifierEncoding::Bitmask + } else { + ModifierEncoding::Xterm + }; + + if enabled { tracing::info!( "Terminal '{}' supports enhanced keys, enabling CSI u protocol", self.capabilities.term_program ); - self.enhanced.enable_enhanced_keys()?; } else { tracing::info!( "Terminal '{}' does not support enhanced keys, using standard input", @@ -379,7 +414,7 @@ impl SmartInput { /// Parse a key sequence. pub fn parse(&self, data: &[u8]) -> Option { - EnhancedInput::parse_key_sequence(data) + EnhancedInput::parse_key_sequence_with_encoding(data, self.capabilities.modifier_encoding) } /// Check if enhanced keys are active. @@ -410,8 +445,8 @@ mod tests { #[test] fn test_parse_csi_u_ctrl_c() { - // Ctrl+C: ESC [ 99 ; 5 u - let seq = "\x1b[99;5u"; + // Ctrl+C: ESC [ 99 ; 4 u (mask 4 = Ctrl only) + let seq = "\x1b[99;4u"; let event = EnhancedInput::parse_key_sequence(seq.as_bytes()).unwrap(); assert_eq!(event.code, KeyCode::Char('c')); assert!(event.modifiers.ctrl); @@ -427,6 +462,22 @@ mod tests { assert!(event.modifiers.shift); } + #[test] + fn test_parse_csi_u_xterm_no_modifiers() { + // Xterm-style no modifiers: ESC [ 97 ; 1 u + let seq = "\x1b[97;1u"; + let event = EnhancedInput::parse_key_sequence_with_encoding( + seq.as_bytes(), + ModifierEncoding::Xterm, + ) + .unwrap(); + assert_eq!(event.code, KeyCode::Char('a')); + assert!(!event.modifiers.shift); + assert!(!event.modifiers.ctrl); + assert!(!event.modifiers.alt); + assert!(!event.modifiers.meta); + } + #[test] fn test_parse_standard_tab() { // Plain Tab @@ -447,19 +498,19 @@ mod tests { #[test] fn test_modifiers_from_csi_u_mask() { // Mask 1 = Shift - let mods = KeyModifiers::from_csi_u_mask(1); + let mods = KeyModifiers::from_csi_u_mask_with_encoding(1, ModifierEncoding::Bitmask); assert!(mods.shift); assert!(!mods.ctrl); assert!(!mods.alt); // Mask 5 = Shift + Ctrl - let mods = KeyModifiers::from_csi_u_mask(5); + let mods = KeyModifiers::from_csi_u_mask_with_encoding(5, ModifierEncoding::Bitmask); assert!(mods.shift); assert!(mods.ctrl); assert!(!mods.alt); // Mask 15 = Shift + Alt + Ctrl + Meta - let mods = KeyModifiers::from_csi_u_mask(15); + let mods = KeyModifiers::from_csi_u_mask_with_encoding(15, ModifierEncoding::Bitmask); assert!(mods.shift); assert!(mods.alt); assert!(mods.ctrl); diff --git a/codi-rs/src/tui/input/mod.rs b/codi-rs/src/tui/input/mod.rs index f247d1a..d5bdf36 100644 --- a/codi-rs/src/tui/input/mod.rs +++ b/codi-rs/src/tui/input/mod.rs @@ -8,6 +8,6 @@ pub mod enhanced; pub use enhanced::{ - detect_terminal_capabilities, EnhancedInput, KeyCode, KeyEvent, KeyModifiers, SmartInput, - TerminalCapabilities, + detect_terminal_capabilities, EnhancedInput, KeyCode, KeyEvent, KeyModifiers, ModifierEncoding, + SmartInput, TerminalCapabilities, }; diff --git a/codi-rs/src/tui/mod.rs b/codi-rs/src/tui/mod.rs index 5c21532..3712b8b 100644 --- a/codi-rs/src/tui/mod.rs +++ b/codi-rs/src/tui/mod.rs @@ -38,15 +38,18 @@ pub mod app; pub mod commands; pub mod components; +pub mod diff; pub mod events; pub mod input; +pub mod search; 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 input::{EnhancedInput, KeyCode, KeyEvent, KeyModifiers, SmartInput}; +pub use input::{EnhancedInput, KeyCode, KeyEvent, KeyModifiers, ModifierEncoding, SmartInput}; +pub use search::{SearchResult, SearchState, SearchableContent}; pub use streaming::{MarkdownStreamCollector, StreamController, StreamState, StreamStatus}; pub use syntax::{HighlightType, SupportedLanguage, SyntaxHighlighter, Theme}; diff --git a/codi-rs/src/tui/search.rs b/codi-rs/src/tui/search.rs new file mode 100644 index 0000000..33b733b --- /dev/null +++ b/codi-rs/src/tui/search.rs @@ -0,0 +1,303 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Search functionality for TUI message history. +//! +//! Provides incremental search with highlighting and navigation. + +use std::collections::HashMap; + +/// A search result found in a message. +#[derive(Debug, Clone)] +pub struct SearchResult { + /// Message ID containing the match. + pub message_id: String, + /// Line number within the message (0-indexed). + pub line_number: usize, + /// Character index (Unicode scalar values) within the line where match starts. + pub char_index: usize, + /// Length of the match in characters. + pub match_length: usize, + /// Context text around the match. + pub context: String, +} + +impl SearchResult { + /// Convert the character index to a byte range for a given line. + pub fn byte_range(&self, line: &str) -> Option<(usize, usize)> { + let char_starts = SearchState::char_starts(line); + if self.char_index >= char_starts.len() { + return None; + } + let start = char_starts[self.char_index]; + let end_char = self.char_index.saturating_add(self.match_length); + let end = *char_starts.get(end_char).unwrap_or(&line.len()); + Some((start, end)) + } +} + +/// Search state for incremental search. +#[derive(Debug, Default, Clone)] +pub struct SearchState { + /// Current search query. + pub query: String, + /// All found results. + pub results: Vec, + /// Currently selected result index. + pub current_index: usize, + /// Whether search is currently active. + pub is_active: bool, + /// Case sensitive search. + pub case_sensitive: bool, +} + +impl SearchState { + /// Create a new empty search state. + pub fn new() -> Self { + Self::default() + } + + /// Activate search mode. + pub fn activate(&mut self) { + self.is_active = true; + self.query.clear(); + self.results.clear(); + self.current_index = 0; + } + + /// Deactivate search mode. + pub fn deactivate(&mut self) { + self.is_active = false; + } + + /// Update search query and find results. + pub fn search(&mut self, query: &str, messages: &[(String, String)]) { + self.query = query.to_string(); + self.results.clear(); + self.current_index = 0; + + if query.is_empty() { + return; + } + + let pattern = regex::escape(query); + let regex = match regex::RegexBuilder::new(&pattern) + .case_insensitive(!self.case_sensitive) + .build() + { + Ok(re) => re, + Err(_) => return, + }; + + for (msg_id, content) in messages { + for (line_num, line) in content.lines().enumerate() { + let mut char_starts: Option> = None; + + for m in regex.find_iter(line) { + let char_starts = char_starts.get_or_insert_with(|| Self::char_starts(line)); + let match_start = m.start(); + let match_end = m.end(); + let char_start = Self::byte_to_char_index(char_starts, match_start); + let char_end = Self::byte_to_char_index(char_starts, match_end); + let match_len = char_end.saturating_sub(char_start); + + // Extract context (60 chars around match) + let total_chars = char_starts.len().saturating_sub(1); + let context_start_char = char_start.saturating_sub(20); + let context_end_char = (char_end + 40).min(total_chars); + let context_start = char_starts[context_start_char]; + let context_end = char_starts[context_end_char]; + let context = &line[context_start..context_end]; + + self.results.push(SearchResult { + message_id: msg_id.clone(), + line_number: line_num, + char_index: char_start, + match_length: match_len, + context: context.to_string(), + }); + } + } + } + } + + fn char_starts(line: &str) -> Vec { + let mut starts: Vec = line.char_indices().map(|(i, _)| i).collect(); + starts.push(line.len()); + starts + } + + fn byte_to_char_index(char_starts: &[usize], byte_index: usize) -> usize { + match char_starts.binary_search(&byte_index) { + Ok(idx) => idx, + Err(idx) => idx.saturating_sub(1), + } + } + + /// Navigate to next result. + pub fn next_result(&mut self) { + if !self.results.is_empty() { + self.current_index = (self.current_index + 1) % self.results.len(); + } + } + + /// Navigate to previous result. + pub fn prev_result(&mut self) { + if !self.results.is_empty() { + self.current_index = if self.current_index == 0 { + self.results.len() - 1 + } else { + self.current_index - 1 + }; + } + } + + /// Get current result. + pub fn current_result(&self) -> Option<&SearchResult> { + self.results.get(self.current_index) + } + + /// Check if there are any results. + pub fn has_results(&self) -> bool { + !self.results.is_empty() + } + + /// Get result count. + pub fn result_count(&self) -> usize { + self.results.len() + } + + /// Toggle case sensitivity. + pub fn toggle_case_sensitive(&mut self) { + self.case_sensitive = !self.case_sensitive; + } +} + +/// Searchable content manager. +pub struct SearchableContent { + /// Map of message ID to content. + content: HashMap, +} + +impl SearchableContent { + /// Create new searchable content. + pub fn new() -> Self { + Self { + content: HashMap::new(), + } + } + + /// Add or update message content. + pub fn set_message(&mut self, id: String, content: String) { + self.content.insert(id, content); + } + + /// Get all content as slice of tuples for searching. + pub fn as_search_slice(&self) -> Vec<(String, String)> { + self.content + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + } + + /// Get message content by ID. + pub fn get(&self, id: &str) -> Option<&str> { + self.content.get(id).map(|s| s.as_str()) + } + + /// Clear all content. + pub fn clear(&mut self) { + self.content.clear(); + } +} + +impl Default for SearchableContent { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_search_finds_matches() { + let mut state = SearchState::new(); + let messages = vec![ + ("msg1".to_string(), "Hello world\nSecond line".to_string()), + ("msg2".to_string(), "World of code".to_string()), + ]; + + state.search("world", &messages); + + assert_eq!(state.result_count(), 2); + assert_eq!(state.results[0].message_id, "msg1"); + assert_eq!(state.results[0].line_number, 0); + assert_eq!(state.results[1].message_id, "msg2"); + } + + #[test] + fn test_search_case_insensitive() { + let mut state = SearchState::new(); + state.case_sensitive = false; + + let messages = vec![("msg1".to_string(), "Hello World".to_string())]; + + state.search("world", &messages); + + assert_eq!(state.result_count(), 1); + } + + #[test] + fn test_search_navigation() { + let mut state = SearchState::new(); + let messages = vec![("msg1".to_string(), "test test test".to_string())]; + + state.search("test", &messages); + assert_eq!(state.result_count(), 3); + + assert_eq!(state.current_index, 0); + state.next_result(); + assert_eq!(state.current_index, 1); + state.next_result(); + assert_eq!(state.current_index, 2); + state.next_result(); + assert_eq!(state.current_index, 0); // Wrap around + } + + #[test] + fn test_empty_query() { + let mut state = SearchState::new(); + let messages = vec![("msg1".to_string(), "Hello world".to_string())]; + + state.search("", &messages); + + assert_eq!(state.result_count(), 0); + } + + #[test] + fn test_no_matches() { + let mut state = SearchState::new(); + let messages = vec![("msg1".to_string(), "Hello world".to_string())]; + + state.search("xyz", &messages); + + assert_eq!(state.result_count(), 0); + assert!(!state.has_results()); + } + + #[test] + fn test_search_unicode_indices() { + let mut state = SearchState::new(); + let messages = vec![("msg1".to_string(), "café 👍".to_string())]; + + state.search("👍", &messages); + + assert_eq!(state.result_count(), 1); + let result = &state.results[0]; + assert_eq!(result.char_index, 5); + assert_eq!(result.match_length, 1); + assert_eq!(result.context, "café 👍"); + } +} diff --git a/codi-rs/src/tui/syntax/highlighter.rs b/codi-rs/src/tui/syntax/highlighter.rs index d45890e..f8c2001 100644 --- a/codi-rs/src/tui/syntax/highlighter.rs +++ b/codi-rs/src/tui/syntax/highlighter.rs @@ -170,6 +170,9 @@ impl SyntaxHighlighter { /// Get or create parser for language. fn get_parser(&mut self, lang: SupportedLanguage) -> Option<&mut Parser> { + if lang == SupportedLanguage::Markdown { + return None; + } if !self.parsers.contains_key(&lang) { let mut parser = Parser::new(); let ts_lang = lang.tree_sitter_language(); @@ -181,6 +184,9 @@ impl SyntaxHighlighter { /// Get or create query for language. fn get_query(&mut self, lang: SupportedLanguage) -> Option<&Query> { + if lang == SupportedLanguage::Markdown { + return None; + } if !self.queries.contains_key(&lang) { let ts_lang = lang.tree_sitter_language(); let query = Query::new(&ts_lang, lang.highlight_query()).ok()?; diff --git a/codi-rs/src/tui/ui.rs b/codi-rs/src/tui/ui.rs index 43e91f1..37db898 100644 --- a/codi-rs/src/tui/ui.rs +++ b/codi-rs/src/tui/ui.rs @@ -14,7 +14,7 @@ use ratatui::{ use crate::types::Role; use super::app::{App, AppMode}; -use super::components::{ExecCellWidget, ToolStatus}; +use super::components::ExecCellWidget; /// Draw the main UI. pub fn draw(f: &mut Frame, app: &App) { 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 index 0f70249..d17b270 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_error.snap @@ -1,25 +1,24 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 99 -expression: terminal.backend() +expression: snapshot --- "┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ✗ Error (0ms) │" -"│ Input: {"cmd":"invalid_command"} │" -"│ Result: 1 lines | Command not found: invalid_command │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" +"│ ✗ Error (#ms)│" +"│ 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 index 48c8d11..493bc1c 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_expanded.snap @@ -1,30 +1,29 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 127 -expression: terminal.backend() +expression: snapshot --- "┌ write_file ──────────────────────────────────────────────────────────────────┐" -"│ ✓ Success (0ms) │" -"│ { │" -"│ "content": "Hello World", │" -"│ "path": "output.txt" │" -"│ } │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ ┌ Output ──────────────────────────────────────────────────────────────────┐ │" -"│ │ File written successfully │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"│ ✓ Success (#ms)│" +"│ {│" +"│ "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 index 080678b..1d339ba 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap @@ -1,25 +1,24 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 157 -expression: terminal.backend() +expression: snapshot --- "┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (3ms) │" -"│ Input: {"cmd":"long_running_command"} │" -"│ ┌──────────────────────────────────────────────────────────────────────────┐ │" -"│ │ Starting process... │ │" -"│ │ Loading configuration │ │" -"│ │ Connecting to database │ │" -"│ │ Executing query │ │" -"│ │ Processing results │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"│ ⠋ Running... (#ms)│" +"│ 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_live_output.snap.new b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap.new deleted file mode 100644 index 871cdfa..0000000 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_live_output.snap.new +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tests/tui_exec_cell.rs -assertion_line: 157 -expression: terminal.backend() ---- -"┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (6ms) │" -"│ 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 index 6a74db2..dee1c46 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap @@ -1,25 +1,24 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 30 -expression: terminal.backend() +expression: snapshot --- "┌ read_file ───────────────────────────────────────────────────────────────────┐" -"│ ○ Running... (0ms) │" -"│ Input: {"path":"test.rs"} │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" +"│ ○ Running... (#ms)│" +"│ Input: {"path":"test.rs"}│" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" +"││" "└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap.new b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap.new deleted file mode 100644 index 68ea5e4..0000000 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_pending.snap.new +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tests/tui_exec_cell.rs -assertion_line: 30 -expression: terminal.backend() ---- -"┌ read_file ───────────────────────────────────────────────────────────────────┐" -"│ ○ Running... (9ms) │" -"│ Input: {"path":"test.rs"} │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap index e94b93d..ae33f7c 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap @@ -1,25 +1,24 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 51 -expression: terminal.backend() +expression: snapshot --- "┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (1ms) │" -"│ Input: {"cmd":"echo hello"} │" -"│ ┌──────────────────────────────────────────────────────────────────────────┐ │" -"│ │ Processing... │ │" -"│ │ Step 1 complete │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ │ │ │" -"│ └──────────────────────────────────────────────────────────────────────────┘ │" +"│ ⠋ Running... (#ms)│" +"│ Input: {"cmd":"echo hello"}│" +"│ ┌──────────────────────────────────────────────────────────────────────────┐│" +"│ │ Processing... ││" +"│ │ Step 1 complete ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ │ ││" +"│ └──────────────────────────────────────────────────────────────────────────┘│" "└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap.new b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap.new deleted file mode 100644 index d63f2cf..0000000 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_running.snap.new +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tests/tui_exec_cell.rs -assertion_line: 51 -expression: terminal.backend() ---- -"┌ bash ────────────────────────────────────────────────────────────────────────┐" -"│ ⠋ Running... (5ms) │" -"│ 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 index c30e070..01ddaf8 100644 --- a/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap +++ b/codi-rs/tests/snapshots/tui_exec_cell__exec_cell_success.snap @@ -1,25 +1,24 @@ --- source: tests/tui_exec_cell.rs -assertion_line: 75 -expression: terminal.backend() +expression: snapshot --- "┌ read_file ───────────────────────────────────────────────────────────────────┐" -"│ ✓ Success (0ms) │" -"│ Input: {"path":"test.rs"} │" -"│ Result: 3 lines | File content hereMultiple linesOf text │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" +"│ ✓ Success (#ms)│" +"│ 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 index da75f35..1162012 100644 --- a/codi-rs/tests/tui_exec_cell.rs +++ b/codi-rs/tests/tui_exec_cell.rs @@ -3,8 +3,10 @@ //! TUI rendering integration tests using insta snapshots. +use ratatui::buffer::Buffer; use ratatui::backend::TestBackend; use ratatui::Terminal; +use regex::Regex; use codi::tui::components::{ExecCell, ExecCellWidget}; @@ -27,7 +29,8 @@ fn test_exec_cell_pending() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_pending", snapshot); } /// Test rendering of a running exec cell with spinner. @@ -48,7 +51,8 @@ fn test_exec_cell_running() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_running", snapshot); } /// Test rendering of a completed exec cell. @@ -72,7 +76,8 @@ fn test_exec_cell_success() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_success", snapshot); } /// Test rendering of a failed exec cell. @@ -96,7 +101,8 @@ fn test_exec_cell_error() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_error", snapshot); } /// Test rendering of expanded exec cell. @@ -124,7 +130,8 @@ fn test_exec_cell_expanded() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_expanded", snapshot); } /// Test live output during execution. @@ -154,5 +161,68 @@ fn test_exec_cell_live_output() { }) .unwrap(); - insta::assert_snapshot!(terminal.backend()); + let snapshot = render_exec_cell_snapshot(&terminal); + insta::assert_snapshot!("exec_cell_live_output", snapshot); +} + +fn render_exec_cell_snapshot(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + let lines = buffer_to_lines(buffer, width); + normalize_snapshot(lines) +} + +fn buffer_to_lines(buffer: &Buffer, width: usize) -> Vec { + let mut lines = Vec::with_capacity(buffer.area.height as usize); + for row in buffer.content.chunks(width) { + let mut line = String::with_capacity(width); + for cell in row { + line.push_str(cell.symbol()); + } + lines.push(line); + } + lines +} + +fn normalize_snapshot(lines: Vec) -> String { + let duration_re = Regex::new(r"\((\d+(?:\.\d+)?)(ms|s)\)").unwrap(); + + lines + .into_iter() + .map(|line| { + let redacted = duration_re.replace_all(&line, |caps: ®ex::Captures<'_>| { + let redacted_num: String = caps[1] + .chars() + .map(|ch| if ch == '.' { '.' } else { '#' }) + .collect(); + format!("({}{})", redacted_num, &caps[2]) + }); + let stripped = strip_right_padding(redacted.as_ref()); + format!("\"{}\"", stripped) + }) + .collect::>() + .join("\n") +} + +fn strip_right_padding(line: &str) -> String { + let chars: Vec = line.chars().collect(); + if chars.len() < 2 { + return line.to_string(); + } + if chars.first() != Some(&'│') || chars.last() != Some(&'│') { + return line.to_string(); + } + + let mut end = chars.len() - 1; + while end > 1 && chars[end - 1] == ' ' { + end -= 1; + } + + let mut out = String::with_capacity(end + 1); + out.push('│'); + for ch in &chars[1..end] { + out.push(*ch); + } + out.push('│'); + out }