Skip to content
Merged
7 changes: 6 additions & 1 deletion codi-rs/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions codi-rs/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,13 @@ pub struct App {
project_path: String,
/// Tab completion hint to display.
pub completion_hint: Option<String>,

/// Resolved configuration from config files and CLI.
config: Option<ResolvedConfig>,
/// 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<tokio::sync::oneshot::Receiver<(Agent, CodiResult<String>)>>,

// Tool execution visualization
/// Manager for tool execution cells.
pub exec_cells: crate::tui::components::ExecCellManager,
Expand Down
18 changes: 9 additions & 9 deletions codi-rs/src/tui/components/diff_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<TestBackend> {
Expand Down
6 changes: 3 additions & 3 deletions codi-rs/src/tui/components/exec_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions codi-rs/src/tui/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
239 changes: 239 additions & 0 deletions codi-rs/src/tui/components/process_footer.rs
Original file line number Diff line number Diff line change
@@ -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<f32>,
}

/// Footer showing all running processes.
pub struct ProcessFooter {
processes: Vec<ProcessInfo>,
}

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);
}
}
Loading