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        // Pi: no outer Box — the tool's render_call and render_result handle their own
258        // background/framing. We just pass through their lines unchanged and add the leading
259        // spacer. No padding or background is applied by the execution component.
260        if renderer.render_self() {
261            let mut lines: Vec<String> = Vec::new();
262            lines.push(String::new()); // Leading spacer (matching pi's Spacer(1))
263
264            // Call render_call at full width (pi passes width to the component directly)
265            let call_lines = renderer.render_call(&self.args, width, theme, &ctx);
266
267            let mut all_lines: Vec<String> = Vec::new();
268            if !call_lines.is_empty() {
269                all_lines.extend(call_lines);
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_lines.is_empty() {
276                        all_lines.push(String::new());
277                    }
278                    all_lines.extend(result_lines);
279                }
280            }
281
282            if !all_lines.is_empty() {
283                // Pass through lines as rendered by the tool (no bg/padding added here).
284                lines.extend(all_lines);
285            }
286
287            return lines;
288        }
289
290        // ── Default shell: colored box wrapping ──
291        let mut lines: Vec<String> = Vec::new();
292        lines.push(String::new()); // Leading spacer (matching pi's Spacer(1))
293
294        let bg_key = self.compute_bg_key(Some(renderer));
295        let bg_ansi = theme.bg_ansi(bg_key).to_string();
296        let theme_clone = theme.clone();
297
298        let padding_x = 1;
299        let content_width = width.saturating_sub(2 * padding_x).max(1);
300        let mut msg_box = TuiBox::new(1, 1, Some(crate::tui::Style::new().bg(bg_ansi)));
301
302        let call_lines = renderer.render_call(&self.args, content_width, &theme_clone, &ctx);
303        let header_text = Text::new(call_lines.join("\n"), 0, 0, None);
304        msg_box.add_child(std::boxed::Box::new(header_text));
305
306        if let Some(ref output) = self.output {
307            let result_lines = renderer.render_result(output, content_width, &theme_clone, &ctx);
308            if !result_lines.is_empty() {
309                let result_text = Text::new(result_lines.join("\n"), 0, 0, None);
310                msg_box.add_child(std::boxed::Box::new(result_text));
311            }
312        }
313
314        lines.extend(msg_box.render(width));
315        lines
316    }
317
318    /// Generic fallback rendering for tools with no renderer.
319    /// Shows tool name, JSON args, and output text (collapsed if long).
320    fn render_generic(&self, theme: &RabTheme, width: usize) -> Vec<String> {
321        let mut lines: Vec<String> = Vec::new();
322        lines.push(String::new()); // Leading spacer (matching pi's Spacer(1))
323
324        let bg_key = self.compute_bg_key(None);
325        let bg_ansi = theme.bg_ansi(bg_key).to_string();
326        let mut msg_box = TuiBox::new(1, 1, Some(crate::tui::Style::new().bg(bg_ansi)));
327
328        // Header: bold tool name + JSON args
329        let args_str = serde_json::to_string(&self.args).unwrap_or_default();
330        let header = if args_str.is_empty() || args_str == "{}" {
331            theme.fg("toolTitle", &theme.bold(&self.name))
332        } else {
333            format!(
334                "{}  {}",
335                theme.fg("toolTitle", &theme.bold(&self.name)),
336                theme.fg("muted", &args_str),
337            )
338        };
339        let header_text = Text::new(header, 0, 0, None);
340        msg_box.add_child(std::boxed::Box::new(header_text));
341
342        // Output text (collapsed if longer than PREVIEW_LINES, no tool-specific formatting)
343        if let Some(ref output) = self.output {
344            let display_text = if self.expanded {
345                output.clone()
346            } else {
347                let lines: Vec<&str> = output.lines().collect();
348                if lines.len() > PREVIEW_LINES {
349                    let preview = lines[..PREVIEW_LINES].join("\n");
350                    format!(
351                        "{}\n{}",
352                        preview,
353                        theme.fg(
354                            "muted",
355                            &format!("... ({} more lines)", lines.len() - PREVIEW_LINES)
356                        ),
357                    )
358                } else {
359                    output.clone()
360                }
361            };
362
363            let fg_key = if self.is_error { "error" } else { "toolOutput" };
364            let styled = display_text
365                .lines()
366                .map(|line| {
367                    if line.is_empty() {
368                        String::new()
369                    } else {
370                        theme.fg(fg_key, line)
371                    }
372                })
373                .collect::<Vec<_>>()
374                .join("\n");
375            let result_text = Text::new(styled, 0, 0, None);
376            msg_box.add_child(std::boxed::Box::new(result_text));
377        }
378
379        lines.extend(msg_box.render(width));
380        lines
381    }
382
383    fn compute_bg_key(&self, renderer: Option<&dyn ToolRenderer>) -> &'static str {
384        if let Some(r) = renderer
385            && let Some(hint) = r.render_bg_key()
386        {
387            return hint;
388        }
389        if !self.is_complete {
390            "toolPendingBg"
391        } else if self.is_error {
392            "toolErrorBg"
393        } else {
394            "toolSuccessBg"
395        }
396    }
397}
398
399/// Format a keybinding action as a concise key hint string.
400fn format_key_hint(action_id: &str) -> String {
401    let keys = keybindings::get_keybindings().get_keys(action_id);
402    if keys.is_empty() {
403        return String::new();
404    }
405    keys[0].clone()
406}
407
408// ═══════════════════════════════════════════════════════════════════
409// Rc wrapper for shared ownership
410// ═══════════════════════════════════════════════════════════════════
411
412pub struct RcToolExec(pub Rc<RefCell<ToolExecComponent>>);
413
414impl Clone for RcToolExec {
415    fn clone(&self) -> Self {
416        Self(self.0.clone())
417    }
418}
419
420impl Component for RcToolExec {
421    fn render(&mut self, width: usize) -> Vec<String> {
422        self.0.borrow_mut().render(width)
423    }
424
425    fn set_expanded(&mut self, expanded: bool) {
426        self.0.borrow_mut().set_expanded(expanded);
427    }
428
429    fn invalidate(&mut self) {
430        self.0.borrow_mut().invalidate();
431    }
432
433    fn is_dirty(&self) -> bool {
434        self.0.borrow().is_dirty()
435    }
436
437    fn clear_dirty(&mut self) {
438        self.0.borrow_mut().clear_dirty();
439    }
440
441    fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
442        self.0.borrow().cache_key(width)
443    }
444
445    fn get_cached_render(&self) -> Option<&RenderCache> {
446        None
447    }
448
449    fn set_cached_render(&mut self, cache: RenderCache) {
450        self.0.borrow_mut().set_cached_render(cache);
451    }
452}