Skip to main content

rab/agent/ui/components/
tool_messages.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4use std::time::Instant;
5
6use crate::agent::extension::{ToolRenderContext, ToolRenderer};
7use crate::agent::ui::theme::{RabTheme, current_theme};
8use crate::tui::Component;
9use crate::tui::component::{RenderCache, RenderCacheKey};
10use crate::tui::components::Text;
11use crate::tui::components::r#box::TuiBox;
12use crate::tui::keybindings;
13
14/// Maximum preview lines when collapsed.
15const PREVIEW_LINES: usize = 10;
16
17/// Combined tool execution component — delegates rendering to tool-specific
18/// ToolRenderer when available, falls back to a simple name+args+output display.
19///
20/// Background transitions:
21/// - Pending (call only, no result) → `toolPendingBg`
22/// - Success (call + result, !is_error) → `toolSuccessBg`
23/// - Error (call + result, is_error) → `toolErrorBg`
24pub struct ToolExecComponent {
25    name: String,
26    renderer: Option<Arc<dyn ToolRenderer>>,
27    args: serde_json::Value,
28    output: Option<String>,
29    is_error: bool,
30    is_complete: bool,
31    expanded: bool,
32    /// When execution started (for live duration display).
33    started_at: Option<Instant>,
34    /// Final duration in seconds, captured when the tool completes.
35    /// While running, duration is computed at render time from `started_at`.
36    final_duration: Option<f64>,
37    /// Tracks when to next invalidate for re-render (1s tick).
38    last_timer_tick: Option<Instant>,
39    /// Tool call ID for this execution (pi's toolCallId).
40    tool_call_id: String,
41    /// Structured details for UI renderer (not sent to LLM).
42    details: Option<serde_json::Value>,
43    /// Shared mutable state per tool execution (pi's context.state).
44    state: Rc<RefCell<serde_json::Value>>,
45    /// Working directory for path resolution in renderers.
46    cwd: String,
47    /// Invalidation sender (for async preview computation).
48    invalidate_tx: Option<tokio::sync::mpsc::UnboundedSender<()>>,
49    /// Dirty tracking for efficient re-render.
50    dirty: bool,
51    /// Render cache.
52    cache: Option<RenderCache>,
53}
54
55impl ToolExecComponent {
56    pub fn new(
57        name: impl Into<String>,
58        renderer: Option<Arc<dyn ToolRenderer>>,
59        args: serde_json::Value,
60        cwd: String,
61        tool_call_id: String,
62    ) -> Self {
63        Self {
64            name: name.into(),
65            renderer,
66            args,
67            output: None,
68            is_error: false,
69            is_complete: false,
70            expanded: false,
71            started_at: None,
72            final_duration: None,
73            last_timer_tick: None,
74            tool_call_id,
75            details: None,
76            state: Rc::new(RefCell::new(serde_json::Value::Object(Default::default()))),
77            cwd,
78            invalidate_tx: None,
79            dirty: true,
80            cache: None,
81        }
82    }
83
84    /// Set the execution start time (for live duration display).
85    pub fn set_started_at(&mut self, instant: Instant) {
86        self.started_at = Some(instant);
87        self.last_timer_tick = Some(instant);
88        self.mark_dirty();
89    }
90
91    /// Set the invalidation sender for async preview computation.
92    pub fn set_invalidate_tx(&mut self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
93        self.invalidate_tx = Some(tx);
94    }
95
96    /// Append text to the output buffer for live streaming (e.g. bang command output).
97    /// Does NOT mark the tool as complete — subsequent `set_result_with_details` finalizes.
98    pub fn append_output(&mut self, text: &str) {
99        let output = self.output.get_or_insert_with(String::new);
100        output.push_str(text);
101        self.mark_dirty();
102    }
103
104    pub fn set_result_with_details(
105        &mut self,
106        output: impl Into<String>,
107        is_error: bool,
108        details: Option<serde_json::Value>,
109    ) {
110        self.output = Some(output.into());
111        self.is_error = is_error;
112        self.is_complete = true;
113        self.details = details;
114        if self.final_duration.is_none()
115            && let Some(start) = self.started_at
116        {
117            self.final_duration = Some(start.elapsed().as_secs_f64());
118        }
119        self.mark_dirty();
120    }
121
122    pub fn set_result(&mut self, output: impl Into<String>, is_error: bool) {
123        self.set_result_with_details(output, is_error, None);
124    }
125
126    /// Create an invalidation channel pair for async preview computation.
127    pub fn make_invalidation_channel() -> (
128        tokio::sync::mpsc::UnboundedSender<()>,
129        tokio::sync::mpsc::UnboundedReceiver<()>,
130    ) {
131        tokio::sync::mpsc::unbounded_channel()
132    }
133
134    fn mark_dirty(&mut self) {
135        self.dirty = true;
136        self.cache = None;
137    }
138
139    fn live_duration(&self) -> Option<f64> {
140        if let Some(dur) = self.final_duration {
141            return Some(dur);
142        }
143        self.started_at.map(|t| t.elapsed().as_secs_f64())
144    }
145
146    /// Tick the timer: marks dirty every 1s to trigger re-render.
147    pub fn tick_timer(&mut self) -> bool {
148        if self.is_complete || self.started_at.is_none() {
149            return false;
150        }
151        let now = Instant::now();
152        let should_invalidate = self
153            .last_timer_tick
154            .is_none_or(|last| now.duration_since(last) >= std::time::Duration::from_secs(1));
155        if should_invalidate {
156            self.last_timer_tick = Some(now);
157            self.mark_dirty();
158            return true;
159        }
160        false
161    }
162
163    fn state_hash(&self) -> u64 {
164        use std::collections::hash_map::DefaultHasher;
165        use std::hash::{Hash, Hasher};
166        let mut hasher = DefaultHasher::new();
167        self.name.hash(&mut hasher);
168        self.args.to_string().hash(&mut hasher);
169        self.is_error.hash(&mut hasher);
170        self.is_complete.hash(&mut hasher);
171        self.live_duration().map(|s| s.to_bits()).hash(&mut hasher);
172        self.output.hash(&mut hasher);
173        hasher.finish()
174    }
175}
176
177impl Component for ToolExecComponent {
178    fn set_expanded(&mut self, expanded: bool) {
179        self.expanded = expanded;
180        self.mark_dirty();
181    }
182
183    fn render(&mut self, width: usize) -> Vec<String> {
184        let theme = current_theme();
185
186        // If tool has a renderer, delegate to it
187        if let Some(ref renderer) = self.renderer {
188            return self.render_with_renderer(renderer.as_ref(), &theme, width);
189        }
190
191        // ── Generic fallback (no tool-specific renderer) ──
192        self.render_generic(&theme, width)
193    }
194
195    fn invalidate(&mut self) {
196        self.mark_dirty();
197    }
198
199    fn is_dirty(&self) -> bool {
200        self.dirty
201    }
202
203    fn clear_dirty(&mut self) {
204        self.dirty = false;
205    }
206
207    fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
208        Some(RenderCacheKey {
209            width,
210            expanded: self.expanded,
211            state_hash: self.state_hash(),
212        })
213    }
214
215    fn get_cached_render(&self) -> Option<&RenderCache> {
216        self.cache.as_ref()
217    }
218
219    fn set_cached_render(&mut self, cache: RenderCache) {
220        self.cache = Some(cache);
221        self.dirty = false;
222    }
223}
224
225impl ToolExecComponent {
226    /// Render using the tool-specific renderer.
227    fn render_with_renderer(
228        &self,
229        renderer: &dyn ToolRenderer,
230        theme: &RabTheme,
231        width: usize,
232    ) -> Vec<String> {
233        let is_partial = !self.is_complete;
234
235        let expand_key = format_key_hint(crate::tui::keybindings::ACTION_APP_TOOLS_EXPAND);
236        let ctx = ToolRenderContext {
237            expanded: self.expanded,
238            args_complete: self.is_complete,
239            is_partial,
240            is_error: self.is_error,
241            tool_call_id: self.tool_call_id.clone(),
242            execution_started: self.started_at.is_some(),
243            cwd: self.cwd.clone(),
244            duration_secs: self.live_duration(),
245            exit_code: None,
246            cancelled: false,
247            was_truncated: false,
248            full_output_path: None,
249            file_path: None,
250            expand_key,
251            details: self.details.clone(),
252            state: self.state.clone(),
253            invalidate: self.invalidate_tx.clone(),
254        };
255
256        // Self-shell: tool controls its own framing (e.g. edit with diff preview)
257        if renderer.render_self() {
258            let mut lines: Vec<String> = Vec::new();
259            lines.push(String::new());
260
261            let bg_key = self.compute_bg_key(Some(renderer));
262            let bg_ansi = theme.bg_ansi(bg_key).to_string();
263            let mut call_box = TuiBox::new(1, 1, Some(crate::tui::Style::new().bg(bg_ansi)));
264
265            let mut all_content = String::new();
266
267            let call_lines = renderer.render_call(&self.args, width, theme, &ctx);
268            if !call_lines.is_empty() {
269                all_content.push_str(&call_lines.join("\n"));
270            }
271
272            if let Some(ref output) = self.output {
273                let result_lines = renderer.render_result(output, width, theme, &ctx);
274                if !result_lines.is_empty() {
275                    if !all_content.is_empty() {
276                        all_content.push('\n');
277                        all_content.push('\n');
278                    }
279                    all_content.push_str(&result_lines.join("\n"));
280                }
281            }
282
283            if !all_content.is_empty() {
284                let call_text = Text::new(all_content, 0, 0, None);
285                call_box.add_child(std::boxed::Box::new(call_text));
286                lines.extend(call_box.render(width));
287            }
288            return lines;
289        }
290
291        // ── Default shell: colored box wrapping ──
292        let bg_key = self.compute_bg_key(Some(renderer));
293        let bg_ansi = theme.bg_ansi(bg_key).to_string();
294        let theme_clone = theme.clone();
295
296        let padding_x = 1;
297        let content_width = width.saturating_sub(2 * padding_x).max(1);
298        let mut msg_box = TuiBox::new(1, 1, Some(crate::tui::Style::new().bg(bg_ansi)));
299
300        let call_lines = renderer.render_call(&self.args, content_width, &theme_clone, &ctx);
301        let header_text = Text::new(call_lines.join("\n"), 0, 0, None);
302        msg_box.add_child(std::boxed::Box::new(header_text));
303
304        if let Some(ref output) = self.output {
305            let result_lines = renderer.render_result(output, content_width, &theme_clone, &ctx);
306            if !result_lines.is_empty() {
307                let result_text = Text::new(result_lines.join("\n"), 0, 0, None);
308                msg_box.add_child(std::boxed::Box::new(result_text));
309            }
310        }
311
312        msg_box.render(width)
313    }
314
315    /// Generic fallback rendering for tools with no renderer.
316    /// Shows tool name, JSON args, and output text (collapsed if long).
317    fn render_generic(&self, theme: &RabTheme, width: usize) -> Vec<String> {
318        let bg_key = self.compute_bg_key(None);
319        let bg_ansi = theme.bg_ansi(bg_key).to_string();
320        let mut msg_box = TuiBox::new(1, 1, Some(crate::tui::Style::new().bg(bg_ansi)));
321
322        // Header: bold tool name + JSON args
323        let args_str = serde_json::to_string(&self.args).unwrap_or_default();
324        let header = if args_str.is_empty() || args_str == "{}" {
325            theme.fg("toolTitle", &theme.bold(&self.name))
326        } else {
327            format!(
328                "{}  {}",
329                theme.fg("toolTitle", &theme.bold(&self.name)),
330                theme.fg("muted", &args_str),
331            )
332        };
333        let header_text = Text::new(header, 0, 0, None);
334        msg_box.add_child(std::boxed::Box::new(header_text));
335
336        // Output text (collapsed if longer than PREVIEW_LINES, no tool-specific formatting)
337        if let Some(ref output) = self.output {
338            let display_text = if self.expanded {
339                output.clone()
340            } else {
341                let lines: Vec<&str> = output.lines().collect();
342                if lines.len() > PREVIEW_LINES {
343                    let preview = lines[..PREVIEW_LINES].join("\n");
344                    format!(
345                        "{}\n{}",
346                        preview,
347                        theme.fg(
348                            "muted",
349                            &format!("... ({} more lines)", lines.len() - PREVIEW_LINES)
350                        ),
351                    )
352                } else {
353                    output.clone()
354                }
355            };
356
357            let fg_key = if self.is_error { "error" } else { "toolOutput" };
358            let styled = display_text
359                .lines()
360                .map(|line| {
361                    if line.is_empty() {
362                        String::new()
363                    } else {
364                        theme.fg(fg_key, line)
365                    }
366                })
367                .collect::<Vec<_>>()
368                .join("\n");
369            let result_text = Text::new(styled, 0, 0, None);
370            msg_box.add_child(std::boxed::Box::new(result_text));
371        }
372
373        msg_box.render(width)
374    }
375
376    fn compute_bg_key(&self, renderer: Option<&dyn ToolRenderer>) -> &'static str {
377        if let Some(r) = renderer
378            && let Some(hint) = r.render_bg_key()
379        {
380            return hint;
381        }
382        if !self.is_complete {
383            "toolPendingBg"
384        } else if self.is_error {
385            "toolErrorBg"
386        } else {
387            "toolSuccessBg"
388        }
389    }
390}
391
392/// Format a keybinding action as a concise key hint string.
393fn format_key_hint(action_id: &str) -> String {
394    let keys = keybindings::get_keybindings().get_keys(action_id);
395    if keys.is_empty() {
396        return String::new();
397    }
398    keys[0].clone()
399}
400
401// ═══════════════════════════════════════════════════════════════════
402// Rc wrapper for shared ownership
403// ═══════════════════════════════════════════════════════════════════
404
405pub struct RcToolExec(pub Rc<RefCell<ToolExecComponent>>);
406
407impl Clone for RcToolExec {
408    fn clone(&self) -> Self {
409        Self(self.0.clone())
410    }
411}
412
413impl Component for RcToolExec {
414    fn render(&mut self, width: usize) -> Vec<String> {
415        self.0.borrow_mut().render(width)
416    }
417
418    fn set_expanded(&mut self, expanded: bool) {
419        self.0.borrow_mut().set_expanded(expanded);
420    }
421
422    fn invalidate(&mut self) {
423        self.0.borrow_mut().invalidate();
424    }
425
426    fn is_dirty(&self) -> bool {
427        self.0.borrow().is_dirty()
428    }
429
430    fn clear_dirty(&mut self) {
431        self.0.borrow_mut().clear_dirty();
432    }
433
434    fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
435        self.0.borrow().cache_key(width)
436    }
437
438    fn get_cached_render(&self) -> Option<&RenderCache> {
439        None
440    }
441
442    fn set_cached_render(&mut self, cache: RenderCache) {
443        self.0.borrow_mut().set_cached_render(cache);
444    }
445}