use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders};
use crate::content::ToolCallStatus;
use crate::widget::{RenderCtx, Renderable, hash_combine, hash_str};
const ARGS_PREVIEW_LIMIT: usize = 60;
#[derive(Debug, Clone)]
pub struct ToolCall {
id: String,
name: String,
status: ToolCallStatus,
args_preview: String,
cached_hash: u64,
}
impl ToolCall {
#[must_use]
pub fn new(id: String, name: String, status: ToolCallStatus, args: &str) -> Self {
let args_preview = truncate(args, ARGS_PREVIEW_LIMIT);
let cached_hash = compute_hash(&id, &name, status, &args_preview);
Self {
id,
name,
status,
args_preview,
cached_hash,
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn status(&self) -> ToolCallStatus {
self.status
}
pub fn set_status(&mut self, status: ToolCallStatus) {
if self.status == status {
return;
}
self.status = status;
self.cached_hash = compute_hash(&self.id, &self.name, self.status, &self.args_preview);
}
}
fn compute_hash(id: &str, name: &str, status: ToolCallStatus, args_preview: &str) -> u64 {
hash_combine(
hash_str(id),
hash_combine(
hash_str(name),
hash_combine(status as u64, hash_str(args_preview)),
),
)
}
fn truncate(s: &str, max: usize) -> String {
let mut out = String::with_capacity(s.len().min(max) + 1);
for (count, ch) in s.chars().enumerate() {
if count == max {
out.push('…');
return out;
}
out.push(ch);
}
out
}
impl Renderable for ToolCall {
fn content_hash(&self) -> u64 {
self.cached_hash
}
fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
3
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let theme = ctx.theme();
let status_glyph = status_glyph(self.status);
let status_color = status_color(self.status, theme);
let header = format!("🔧 {} {}", self.name, status_glyph);
let header_style = Style::default()
.fg(theme.colors.tool)
.add_modifier(Modifier::BOLD);
let status_style = Style::default().fg(status_color);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.colors.border));
block = block.title(header.clone()).title_style(header_style);
block.render(area, ctx.buffer_mut());
let inner_y = area.y.saturating_add(1);
if inner_y >= area.y.saturating_add(area.height) {
return;
}
let line = ratatui::text::Line::from(ratatui::text::Span::styled(
format!(" {}", self.args_preview),
status_style,
));
ctx.buffer_mut().set_line(
area.x.saturating_add(1),
inner_y,
&line,
area.width.saturating_sub(2),
);
}
}
fn status_glyph(status: ToolCallStatus) -> &'static str {
match status {
ToolCallStatus::Pending => "…",
ToolCallStatus::Running => "⏳",
ToolCallStatus::Completed => "✔",
ToolCallStatus::Failed => "✘",
}
}
fn status_color(status: ToolCallStatus, theme: &crate::theme::Theme) -> Color {
match status {
ToolCallStatus::Pending => theme.colors.muted,
ToolCallStatus::Running => theme.colors.info,
ToolCallStatus::Completed => theme.colors.success,
ToolCallStatus::Failed => theme.colors.error,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn render_tool_call(tc: &mut ToolCall, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = RenderCtx::new(frame, &theme, &caps);
tc.render(
Rect {
x: 0,
y: 0,
width,
height,
},
&mut ctx,
);
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn tool_call_pending_renders_dots() {
let mut tc = ToolCall::new(
"call-1".into(),
"search".into(),
ToolCallStatus::Pending,
"{}",
);
let buf = render_tool_call(&mut tc, 30, 3);
let top: String = (0..30).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
top.contains("search"),
"title should contain tool name, got {top:?}"
);
assert!(
top.contains('…'),
"pending should render … glyph, got {top:?}"
);
assert_eq!(buf[(0, 0)].symbol(), "┌");
assert_eq!(buf[(29, 0)].symbol(), "┐");
}
#[test]
fn tool_call_completed_renders_status() {
let mut tc = ToolCall::new(
"call-2".into(),
"search".into(),
ToolCallStatus::Completed,
"{}",
);
let buf = render_tool_call(&mut tc, 30, 3);
let top: String = (0..30).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
top.contains('✔'),
"completed should render ✔ glyph, got {top:?}"
);
}
#[test]
fn tool_call_running_renders_hourglass() {
let mut tc = ToolCall::new(
"call-3".into(),
"search".into(),
ToolCallStatus::Running,
"{}",
);
let buf = render_tool_call(&mut tc, 30, 3);
let top: String = (0..30).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
top.contains('⏳'),
"running should render ⏳ glyph, got {top:?}"
);
}
#[test]
fn tool_call_hash_changes_on_status() {
let pending = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Pending, "{}");
let completed = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Completed, "{}");
assert_ne!(pending.content_hash(), completed.content_hash());
}
#[test]
fn tool_call_hash_changes_on_args() {
let a = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Completed, "{foo:1}");
let b = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Completed, "{foo:2}");
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn tool_call_truncates_long_args() {
let long = "x".repeat(ARGS_PREVIEW_LIMIT + 20);
let tc = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Completed, &long);
assert!(tc.args_preview.ends_with('…'));
assert!(tc.args_preview.chars().count() <= ARGS_PREVIEW_LIMIT + 1);
}
#[test]
fn tool_call_set_status_updates_hash() {
let mut tc = ToolCall::new("a".into(), "n".into(), ToolCallStatus::Pending, "{}");
let before = tc.content_hash();
tc.set_status(ToolCallStatus::Completed);
assert_ne!(before, tc.content_hash());
}
}