use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::Span,
widgets::Widget,
};
use super::block_tool::BlockTool;
use super::inline_tool::ToolStatus;
use crate::widgets::ai_chat::components::theme::ChatColors;
pub struct ToolBash<'a> {
command: &'a str,
working_dir: Option<&'a str>,
output: Option<String>,
exit_code: Option<i32>,
executing: bool,
expanded: bool,
}
impl<'a> ToolBash<'a> {
pub fn new(command: &'a str) -> Self {
Self {
command,
working_dir: None,
output: None,
exit_code: None,
executing: false,
expanded: false,
}
}
pub fn working_dir(mut self, dir: Option<&'a str>) -> Self {
self.working_dir = dir;
self
}
pub fn output(mut self, output: Option<String>) -> Self {
self.output = output;
self
}
pub fn exit_code(mut self, code: Option<i32>) -> Self {
self.exit_code = code;
self
}
pub fn executing(mut self, executing: bool) -> Self {
self.executing = executing;
self
}
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
fn get_status(&self) -> ToolStatus {
if self.executing {
ToolStatus::Pending
} else if let Some(code) = self.exit_code {
if code == 0 {
ToolStatus::Complete
} else {
ToolStatus::Error
}
} else {
ToolStatus::Pending
}
}
pub fn render(&self, area: Rect, buf: &mut Buffer, colors: &ChatColors) {
if area.height < 1 {
return;
}
let max_y = area.y + area.height;
let mut y = area.y;
let border_color = if self.executing {
colors.warning
} else if let Some(code) = self.exit_code {
if code == 0 {
colors.success
} else {
colors.error
}
} else {
colors.primary
};
for y_pos in area.y..max_y {
buf.get_mut(area.x, y_pos)
.set_char('│')
.set_style(Style::default().fg(border_color));
}
let header = self.render_header(colors);
buf.set_span(area.x + 2, y, &header, area.width.saturating_sub(3));
y += 1;
if y < max_y {
buf.set_span(
area.x + 2,
y,
&Span::styled(
"─".repeat(30),
Style::default()
.fg(border_color)
.add_modifier(Modifier::DIM),
),
area.width.saturating_sub(3),
);
y += 1;
}
if y < max_y {
let cmd_span = Span::styled(
format!("$ {}", self.command),
Style::default()
.fg(colors.text)
.add_modifier(Modifier::BOLD),
);
buf.set_span(area.x + 2, y, &cmd_span, area.width.saturating_sub(3));
y += 1;
}
if let Some(dir) = self.working_dir {
if y < max_y {
let dir_span = Span::styled(
format!("📁 {}", dir),
Style::default().fg(colors.text_muted),
);
buf.set_span(area.x + 2, y, &dir_span, area.width.saturating_sub(3));
y += 1;
}
}
if let Some(output) = &self.output {
let output_lines: Vec<&str> = output.lines().collect();
let needs_expansion = output_lines.len() > 10;
if y < max_y {
let expand_text = if self.expanded {
"▼ Output"
} else if needs_expansion {
"▶ Output (click to expand)"
} else {
"▶ Output"
};
let expand_span = Span::styled(
expand_text,
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD),
);
buf.set_span(area.x + 2, y, &expand_span, area.width.saturating_sub(3));
y += 1;
}
if self.expanded || !needs_expansion {
let display_lines = if self.expanded {
output_lines.len()
} else {
output_lines.len().min(10)
};
for (i, line) in output_lines.iter().take(display_lines).enumerate() {
if y >= max_y {
break;
}
let line_text = if line.len() > area.width as usize - 4 {
format!("{}...", &line[..area.width as usize - 7])
} else {
line.to_string()
};
buf.set_span(
area.x + 2,
y,
&Span::styled(line_text, Style::default().fg(colors.text_muted)),
area.width.saturating_sub(3),
);
y += 1;
}
if self.expanded && output_lines.len() > display_lines && y < max_y {
buf.set_span(
area.x + 2,
y,
&Span::styled(
format!("... {} more lines", output_lines.len() - display_lines),
Style::default()
.fg(colors.text_muted)
.add_modifier(Modifier::ITALIC),
),
area.width.saturating_sub(3),
);
y += 1;
}
}
}
if let Some(code) = self.exit_code {
if y < max_y {
let (code_text, code_color) = if code == 0 {
("✓ Success", colors.success)
} else {
(format!("✗ Exit code: {}", code), colors.error)
};
let code_span = Span::styled(
code_text,
Style::default().fg(code_color).add_modifier(Modifier::BOLD),
);
buf.set_span(area.x + 2, y, &code_span, area.width.saturating_sub(3));
}
}
}
fn render_header(&self, colors: &ChatColors) -> Span<'static> {
let icon = '$';
let status_text = if self.executing {
let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let frame = spinner_frames[(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
/ 100) as usize
% spinner_frames.len()];
format!("{} Executing...", frame)
} else if self.exit_code.is_some() {
"✓ Done".to_string()
} else {
"Bash".to_string()
};
let style = if self.executing {
Style::default()
.fg(colors.warning)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(colors.text)
.add_modifier(Modifier::BOLD)
};
Span::styled(format!("{} {}", icon, status_text), style)
}
pub fn toggle_expanded(&mut self) {
self.expanded = !self.expanded;
}
}
impl Widget for ToolBash<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = ChatColors::default();
self.render(area, buf, &colors);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_bash_basic() {
let bash = ToolBash::new("ls -la");
assert_eq!(bash.command, "ls -la");
}
#[test]
fn test_tool_bash_with_options() {
let bash = ToolBash::new("cargo build")
.working_dir(Some("/home/user"))
.output(Some("Compiling...".to_string()))
.exit_code(Some(0));
assert!(bash.working_dir.is_some());
assert!(bash.output.is_some());
}
#[test]
fn test_tool_bash_status() {
let pending = ToolBash::new("sleep 1").executing(true);
assert_eq!(pending.get_status(), ToolStatus::Pending);
let success = ToolBash::new("echo hello").exit_code(Some(0));
assert_eq!(success.get_status(), ToolStatus::Complete);
let error = ToolBash::new("false").exit_code(Some(1));
assert_eq!(error.get_status(), ToolStatus::Error);
}
}