1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Borders, Paragraph, Widget},
7};
8
9use crate::state::{AppState, ToolUseStatus};
10use crate::theme;
11use crate::widgets::spinner::FRAMES;
12
13pub struct ToolHistory<'a> {
14 pub state: &'a mut AppState,
15}
16
17impl<'a> ToolHistory<'a> {
18 pub fn new(state: &'a mut AppState) -> Self {
19 Self { state }
20 }
21}
22
23#[derive(Clone, Copy)]
24pub enum HistoryRow {
25 Tool { msg: usize, tool: usize },
26 Sub { msg: usize, tool: usize, sub: usize },
27}
28
29pub fn flat_tools(state: &AppState) -> Vec<(usize, usize)> {
30 let mut out = Vec::new();
31 for (mi, m) in state.conversation.messages.iter().enumerate() {
32 for (ti, _t) in m.tool_uses.iter().enumerate() {
33 out.push((mi, ti));
34 }
35 }
36 out
37}
38
39pub fn flat_rows(state: &AppState) -> Vec<HistoryRow> {
40 let mut out = Vec::new();
41 for (mi, m) in state.conversation.messages.iter().enumerate() {
42 for (ti, t) in m.tool_uses.iter().enumerate() {
43 out.push(HistoryRow::Tool { msg: mi, tool: ti });
44 for (si, _) in t.sub_progress.iter().enumerate() {
45 out.push(HistoryRow::Sub { msg: mi, tool: ti, sub: si });
46 }
47 }
48 }
49 out
50}
51
52fn pretty_name(name: &str) -> &str {
53 match name {
54 "bash" => "Bash",
55 "read" => "Read",
56 "file_write" => "Write",
57 "file_edit" => "Edit",
58 "glob" => "Glob",
59 "grep" => "Grep",
60 "web_fetch" => "WebFetch",
61 "web_search" => "WebSearch",
62 "todo_write" => "TodoWrite",
63 "todo_read" => "TodoRead",
64 "ask_user_question" => "AskUser",
65 "agent" => "Agent",
66 "explore" => "Explore",
67 "delegate_to_all_interns" => "AllInterns",
68 other => other,
69 }
70}
71
72fn truncate_path(s: &str, max: usize) -> String {
73 use unicode_width::UnicodeWidthChar;
74 let total: usize = s.chars().map(|c| c.width().unwrap_or(0)).sum();
75 if total <= max { return s.to_string(); }
76 if s.contains('/') {
77 let parts: Vec<&str> = s.split('/').collect();
78 if let Some(last) = parts.last() {
79 let last_w: usize = last.chars().map(|c| c.width().unwrap_or(0)).sum();
80 if last_w + 4 <= max {
81 return format!("…/{last}");
82 }
83 }
84 }
85 let mut out = String::new();
86 let mut w = 0usize;
87 for c in s.chars() {
88 let cw = c.width().unwrap_or(0);
89 if w + cw + 1 > max { break; }
90 w += cw;
91 out.push(c);
92 }
93 out.push('…');
94 out
95}
96
97impl<'a> Widget for ToolHistory<'a> {
98 fn render(self, area: Rect, buf: &mut Buffer) {
99 let block = Block::default()
100 .borders(Borders::RIGHT)
101 .border_style(Style::default().fg(theme::OVERLAY()));
102 let inner = block.inner(area);
103 block.render(area, buf);
104
105 let header = Line::from(vec![
106 Span::styled(" ", Style::default()),
107 Span::styled(
108 "Tools",
109 Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD),
110 ),
111 Span::styled(
112 if self.state.tool_history.focused { " ●" } else { "" },
113 Style::default().fg(theme::IRIS()),
114 ),
115 ]);
116
117 let mut lines: Vec<Line<'static>> = vec![header, Line::from("")];
118
119 let rows = flat_rows(self.state);
120 let row_width = inner.width.saturating_sub(2) as usize;
121 let name_w = 10usize;
122 let summary_w = row_width.saturating_sub(name_w + 4);
123 let sub_w = row_width.saturating_sub(4);
124
125 let visible = (inner.height as usize).saturating_sub(2).max(1);
126 let total = rows.len();
127 let selected = self.state.tool_history.selected;
128 if let Some(sel) = selected {
129 let scr = &mut self.state.tool_history.scroll;
130 if sel < *scr { *scr = sel; }
131 else if sel >= *scr + visible { *scr = sel + 1 - visible; }
132 } else if total > visible {
133 self.state.tool_history.scroll = total - visible;
134 }
135 let scroll = self.state.tool_history.scroll.min(total.saturating_sub(visible));
136
137 for (i, row) in rows.iter().enumerate().skip(scroll).take(visible) {
138 let is_selected = selected == Some(i);
139 let row_style = if is_selected {
140 Style::default().bg(theme::HL_MED()).add_modifier(Modifier::BOLD)
141 } else {
142 Style::default()
143 };
144 let prefix = if is_selected { "▶ " } else { " " };
145
146 match row {
147 HistoryRow::Tool { msg, tool } => {
148 let tool = &self.state.conversation.messages[*msg].tool_uses[*tool];
149 let (dot, dot_col) = match tool.status {
150 ToolUseStatus::Running => (
151 FRAMES[self.state.spinner_frame % FRAMES.len()].to_string(),
152 theme::GOLD(),
153 ),
154 ToolUseStatus::Completed => ("●".into(), theme::SUCCESS()),
155 ToolUseStatus::Error => ("●".into(), theme::ERROR()),
156 };
157 let pretty = pretty_name(&tool.name);
158 let name_padded = format!("{:<name_w$}", pretty, name_w = name_w);
159 let summary = truncate_path(&tool.input_summary, summary_w);
160 lines.push(Line::from(vec![
161 Span::styled(prefix, Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD)),
162 Span::styled(format!("{dot} "), Style::default().fg(dot_col).add_modifier(Modifier::BOLD)),
163 Span::styled(name_padded, row_style.fg(theme::TEXT())),
164 Span::styled(summary, row_style.fg(theme::TEXT_MUTED())),
165 ]));
166 }
167 HistoryRow::Sub { msg, tool, sub } => {
168 let parent = &self.state.conversation.messages[*msg].tool_uses[*tool];
169 let text = parent.sub_progress.get(*sub).cloned().unwrap_or_default();
170 let truncated = truncate_path(&text, sub_w);
171 lines.push(Line::from(vec![
172 Span::styled(prefix, Style::default().fg(theme::IRIS())),
173 Span::styled("↪ ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::DIM)),
174 Span::styled(truncated, row_style.fg(theme::SUBTLE()).add_modifier(Modifier::ITALIC)),
175 ]));
176 }
177 }
178 }
179
180 if total == 0 {
181 lines.push(Line::from(Span::styled(
182 " no tool calls yet",
183 Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::ITALIC),
184 )));
185 } else if total > visible {
186 lines.push(Line::from(Span::styled(
187 format!(" {} / {} rows", (scroll + visible).min(total), total),
188 Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::DIM),
189 )));
190 }
191
192 Paragraph::new(lines).render(inner, buf);
193 }
194}