Skip to main content

stynx_code_tui/widgets/
tool_history.rs

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}