Skip to main content

rpi_tui/
tool_execution.rs

1//! Tool execution component for displaying tool calls.
2//!
3//! Based on TypeScript implementation:
4//! packages/coding-agent/src/modes/interactive/components/tool-execution.ts
5
6use std::any::Any;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9use std::time::Instant;
10
11use super::component::Component;
12use crate::ansi::{bold, strip_ansi};
13use crate::theme::{theme, theme_revision, ThemeColors};
14use crate::utils::{apply_background_to_line, truncate_to_width, wrap_text_with_ansi};
15
16/// Maximum diff lines rendered inline when expanded before collapsing the rest.
17const DIFF_LINE_CAP: usize = 40;
18/// Diff lines shown when collapsed — a preview density that keeps a big edit
19/// from dumping 40 lines into the transcript while still showing what changed.
20/// Ctrl+O expands to [`DIFF_LINE_CAP`].
21const DIFF_PREVIEW_LINES: usize = 6;
22/// Maximum visual rows shown for a regular tool result while collapsed.
23/// Read/grep/find output can be hundreds of physical lines; keeping a compact
24/// preview prevents one tool from taking over the conversation transcript.
25const OUTPUT_PREVIEW_LINES: usize = 12;
26
27/// Tool execution status.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ToolStatus {
30    /// Tool is pending execution
31    Pending,
32    /// Tool is running
33    Running,
34    /// Tool completed successfully
35    Completed,
36    /// Tool failed
37    Failed,
38}
39
40/// Component that displays a tool execution.
41///
42/// Mirrors TypeScript ToolExecutionComponent class (simplified).
43pub struct ToolExecutionComponent {
44    /// Tool name
45    name: Mutex<String>,
46    /// Optional display title. The raw name is retained for argument parsing
47    /// while extensions such as skill loading can provide a clearer label.
48    display_title: Mutex<Option<String>>,
49    /// When set, the component renders in the native-Pi skill-invocation
50    /// style: a `[skill] <name>` box on the custom-message background that
51    /// collapses to a single line and expands to the full skill markdown
52    /// content (the `read` result). Mirrors pi's `SkillInvocationMessageComponent`.
53    skill_name: Mutex<Option<String>>,
54    /// Tool arguments (displayed)
55    args: Mutex<String>,
56    /// Tool result (displayed after execution)
57    result: Mutex<Option<String>>,
58    /// Execution status
59    status: Mutex<ToolStatus>,
60    /// When the tool started running (for elapsed time display)
61    started_at: Mutex<Option<Instant>>,
62    /// When the tool finished (for total elapsed time display)
63    finished_at: Mutex<Option<Instant>>,
64    /// Whether expanded
65    expanded: Mutex<bool>,
66    /// Optional pre-rendered colored diff lines (from `render_diff`). When
67    /// present the diff is always shown regardless of `expanded` — the diff
68    /// IS the useful content for an `edit` tool.
69    diff_lines: Mutex<Option<Vec<String>>>,
70    /// Render the result body as markdown (tables, bold, lists) instead of
71    /// plain text. Tools opt in by setting `details.markdown = true` on their
72    /// result; the interactive host calls [`Self::set_result_markdown`] when
73    /// it sees that flag. See [`Self::set_result_markdown`].
74    result_markdown: Mutex<bool>,
75    /// Complete todo snapshot, rendered as a single compact task list.
76    todo_state: Mutex<Option<Vec<(String, String)>>>,
77    plan_state: Mutex<Option<(String, bool)>>,
78    /// Bumped by every mutator. A finished tool panel is immutable (no live
79    /// elapsed timer), so its rendered lines are memoized by width; the
80    /// revision drops the cache when args/result/expand change.
81    revision: AtomicU64,
82    /// Memoized render of a `Completed`/`Failed` panel. Mirrors upstream's
83    /// per-component render cache (`tool-execution.ts` + `layout.ts
84    /// renderCached`), which is what keeps a transcript repaint from
85    /// re-rendering every finished tool panel.
86    cache: Mutex<Option<ToolRenderCache>>,
87}
88
89#[derive(Clone)]
90struct ToolRenderCache {
91    width: usize,
92    revision: u64,
93    theme_revision: u64,
94    lines: Vec<String>,
95}
96
97impl ToolExecutionComponent {
98    /// Create a new tool execution component.
99    pub fn new(name: &str, args: &str) -> Self {
100        Self {
101            name: Mutex::new(name.to_string()),
102            display_title: Mutex::new(None),
103            skill_name: Mutex::new(None),
104            args: Mutex::new(args.to_string()),
105            result: Mutex::new(None),
106            status: Mutex::new(ToolStatus::Pending),
107            started_at: Mutex::new(None),
108            finished_at: Mutex::new(None),
109            expanded: Mutex::new(false),
110            diff_lines: Mutex::new(None),
111            result_markdown: Mutex::new(false),
112            todo_state: Mutex::new(None),
113            plan_state: Mutex::new(None),
114            revision: AtomicU64::new(0),
115            cache: Mutex::new(None),
116        }
117    }
118
119    /// Invalidate the memoized render (state changed).
120    fn bump(&self) {
121        self.revision.fetch_add(1, Ordering::AcqRel);
122    }
123
124    /// Set the tool arguments.
125    pub fn set_args(&self, args: &str) {
126        if let Ok(mut a) = self.args.lock() {
127            *a = args.to_string();
128        }
129        self.bump();
130    }
131
132    /// Set the tool result.
133    pub fn set_result(&self, result: &str, is_error: bool) {
134        if let Ok(mut r) = self.result.lock() {
135            // The header glyph + bg tint already convey status (✗/red for
136            // failure, ✓/green for success), so the result text is stored
137            // raw — no redundant emoji prefix (pi doesn't double these up).
138            *r = Some(result.to_string());
139        }
140        if let Ok(mut s) = self.status.lock() {
141            *s = if is_error {
142                ToolStatus::Failed
143            } else {
144                ToolStatus::Completed
145            };
146        }
147        if let Ok(mut f) = self.finished_at.lock() {
148            *f = Some(Instant::now());
149        }
150        self.bump();
151    }
152
153    /// Mark as running.
154    pub fn set_running(&self) {
155        if let Ok(mut s) = self.status.lock() {
156            *s = ToolStatus::Running;
157        }
158        if let Ok(mut t) = self.started_at.lock() {
159            *t = Some(Instant::now());
160        }
161        self.bump();
162    }
163
164    /// Set expanded state.
165    pub fn set_expanded(&self, expanded: bool) {
166        if let Ok(mut e) = self.expanded.lock() {
167            *e = expanded;
168        }
169        self.bump();
170    }
171
172    /// Whether the component is currently expanded (for toggle helpers).
173    pub fn is_expanded(&self) -> bool {
174        *self.expanded.lock().unwrap()
175    }
176
177    /// Get the status.
178    pub fn status(&self) -> ToolStatus {
179        *self.status.lock().unwrap()
180    }
181
182    /// Get the tool name.
183    pub fn name(&self) -> String {
184        self.name.lock().unwrap().clone()
185    }
186
187    /// Override the title shown in the header without changing the underlying
188    /// tool name used to summarize arguments and route updates.
189    pub fn set_display_title(&self, title: impl Into<String>) {
190        if let Ok(mut display_title) = self.display_title.lock() {
191            *display_title = Some(title.into());
192        }
193        self.bump();
194    }
195
196    /// Mark this component as a skill-invocation read: it renders in the
197    /// native-Pi `[skill] <name>` box style (custom-message background,
198    /// collapsible to the full skill markdown). Pass the skill name.
199    pub fn set_skill_name(&self, name: impl Into<String>) {
200        if let Ok(mut skill_name) = self.skill_name.lock() {
201            *skill_name = Some(name.into());
202        }
203        self.bump();
204    }
205
206    /// Whether this component displays a skill invocation (see
207    /// [`Self::set_skill_name`]).
208    pub fn is_skill(&self) -> bool {
209        self.skill_name.lock().map(|s| s.is_some()).unwrap_or(false)
210    }
211
212    /// The skill name when this component is a skill invocation.
213    pub fn skill_name(&self) -> Option<String> {
214        self.skill_name.lock().unwrap().clone()
215    }
216
217    /// Attach a pre-rendered colored diff (from [`crate::diff::render_diff`]).
218    /// When set, the diff lines are always shown (regardless of `expanded`)
219    /// so an edit's changes are visible directly in the transcript.
220    pub fn set_diff(&self, lines: Vec<String>) {
221        if let Ok(mut d) = self.diff_lines.lock() {
222            *d = Some(lines);
223        }
224        self.bump();
225    }
226
227    /// Render the result body as markdown instead of plain indented text.
228    ///
229    /// The host turns this on when a tool result carries `details.markdown =
230    /// true`, so a tool whose payload is markdown (a todo table, a report)
231    /// gets its tables/lists/bold rendered by the shared markdown component
232    /// rather than printed literally. Markdown rows already wrap themselves to
233    /// the panel width, so they are not re-wrapped here.
234    pub fn set_result_markdown(&self, enabled: bool) {
235        if let Ok(mut markdown) = self.result_markdown.lock() {
236            *markdown = enabled;
237        }
238        self.bump();
239    }
240
241    /// Install plain task rows; the host owns plugin snapshot parsing.
242    pub fn set_todo_state(&self, rows: Vec<(String, String)>) {
243        *self.todo_state.lock().unwrap() = Some(rows);
244        self.bump();
245    }
246
247    pub fn is_todo_list(&self) -> bool {
248        self.todo_state.lock().unwrap().is_some()
249    }
250
251    pub fn set_plan_state(&self, plan: String, active: bool) {
252        *self.plan_state.lock().unwrap() = Some((plan, active));
253        self.bump();
254    }
255
256    pub fn is_plan_panel(&self) -> bool {
257        self.plan_state.lock().unwrap().is_some()
258    }
259}
260
261impl Component for ToolExecutionComponent {
262    fn render(&self, width: usize) -> Vec<String> {
263        let status = *self.status.lock().unwrap();
264        let revision = self.revision.load(Ordering::Acquire);
265        let theme_revision = theme_revision();
266
267        // A finished panel is immutable; a running/pending one has a live
268        // elapsed timer and may still stream, so only the finished states are
269        // memoized. (Mirrors upstream's width-keyed cache, gated on the
270        // elapsed readout no longer advancing.)
271        let finished = matches!(status, ToolStatus::Completed | ToolStatus::Failed);
272        if finished {
273            if let Ok(cache) = self.cache.lock() {
274                if let Some(cached) = cache.as_ref() {
275                    if cached.width == width
276                        && cached.revision == revision
277                        && cached.theme_revision == theme_revision
278                    {
279                        return cached.lines.clone();
280                    }
281                }
282            }
283        }
284
285        let colors = theme().colors;
286        if let Some((plan, active)) = self.plan_state.lock().unwrap().as_ref() {
287            let status = if *active {
288                "Planning"
289            } else if plan.trim().is_empty() {
290                "Idle"
291            } else {
292                "Ready"
293            };
294            let mut rows = vec![
295                String::new(),
296                truncate_to_width(
297                    &colors.accent.fg(&bold(&format!(" Plan · {status}"))),
298                    width,
299                    "…",
300                ),
301            ];
302            if plan.trim().is_empty() {
303                let hint = if *active {
304                    "Inspect the repository, then complete the plan."
305                } else {
306                    "No saved plan. Use /plan start."
307                };
308                for line in wrap_text_with_ansi(hint, width.saturating_sub(2).max(1)) {
309                    rows.push(truncate_to_width(
310                        &format!("  {}", colors.muted.fg(&line)),
311                        width,
312                        "…",
313                    ));
314                }
315            } else {
316                let content = crate::markdown::Markdown::new(plan.clone(), 0, 0)
317                    .render(width.saturating_sub(2).max(1));
318                let expanded = *self.expanded.lock().unwrap();
319                let shown = if expanded {
320                    content.len()
321                } else {
322                    content.len().min(16)
323                };
324                for line in content.iter().take(shown) {
325                    rows.push(truncate_to_width(&format!("  {line}"), width, "…"));
326                }
327                if content.len() > shown {
328                    rows.push(truncate_to_width(
329                        &colors.muted.fg(&format!(
330                            "  … {} more lines · Ctrl+O or /plan show",
331                            content.len() - shown
332                        )),
333                        width,
334                        "…",
335                    ));
336                }
337            }
338            return rows;
339        }
340        if let Some(todos) = self.todo_state.lock().unwrap().as_ref() {
341            let done = todos
342                .iter()
343                .filter(|(_, status)| status == "completed")
344                .count();
345            let heading = format!(" Tasks  {done}/{} completed", todos.len());
346            let mut rows = vec![
347                String::new(),
348                truncate_to_width(&colors.tool_title.fg(&bold(&heading)), width, "…"),
349            ];
350            if todos.is_empty() {
351                rows.push(colors.muted.fg("   No tasks"));
352            }
353            for (text, status) in todos {
354                let (mark, color) = match status.as_str() {
355                    "completed" => ("[✓]", colors.muted),
356                    "in_progress" => ("[•]", colors.accent),
357                    _ => ("[ ]", colors.tool_output),
358                };
359                for (index, line) in wrap_text_with_ansi(text, width.saturating_sub(7).max(1))
360                    .into_iter()
361                    .enumerate()
362                {
363                    let label = if status == "completed" {
364                        format!("\x1b[9m{line}\x1b[29m")
365                    } else {
366                        line
367                    };
368                    let prefix = if index == 0 { mark } else { "   " };
369                    rows.push(truncate_to_width(
370                        &format!("   {} {}", color.fg(prefix), color.fg(&label)),
371                        width,
372                        "…",
373                    ));
374                }
375            }
376            return rows;
377        }
378        let mut lines = Vec::new();
379
380        // Top spacer — pi's tool-execution.ts adds a Spacer(1) child inside
381        // the component so each tool panel is visually separated from the
382        // surrounding transcript (the old rpi drain path had no gap between
383        // consecutive tool panels).
384        lines.push(String::new());
385
386        let name = self.name.lock().unwrap();
387        let display_title = self.display_title.lock().unwrap();
388        let skill_name = self.skill_name.lock().unwrap();
389        let status = self.status.lock().unwrap();
390        let args = self.args.lock().unwrap();
391        let result = self.result.lock().unwrap();
392        let expanded = self.expanded.lock().unwrap();
393        let diff_lines = self.diff_lines.lock().unwrap();
394        let result_markdown = self.result_markdown.lock().unwrap();
395        let started_at = self.started_at.lock().unwrap();
396        let finished_at = self.finished_at.lock().unwrap();
397
398        // Skill invocation mode: render as native-Pi's `[skill]` box rather
399        // than a generic tool panel — custom-message background, collapsed to
400        // a single `[skill] <name> (Ctrl+O to expand)` line, expanded to the
401        // full skill markdown (the `read` result). Mirrors pi's
402        // `SkillInvocationMessageComponent` while reusing the tool panel's
403        // result streaming + Ctrl+O expand plumbing.
404        if let Some(skill) = skill_name.as_deref() {
405            let content = result.as_deref().unwrap_or("");
406            let lines = render_skill(&colors, skill, content, *expanded, width);
407            if finished {
408                if let Ok(mut cache) = self.cache.lock() {
409                    *cache = Some(ToolRenderCache {
410                        width,
411                        revision,
412                        theme_revision,
413                        lines: lines.clone(),
414                    });
415                }
416            }
417            return lines;
418        }
419
420        // Status indicator — a colored glyph (no emoji), pi style.
421        let (status_icon, status_color) = match *status {
422            ToolStatus::Pending => ("●", colors.muted),
423            ToolStatus::Running => ("●", colors.accent),
424            ToolStatus::Completed => ("✓", colors.success),
425            ToolStatus::Failed => ("✗", colors.error),
426        };
427
428        // Background tint for the header row reflects status (pi tool*Bg).
429        let bg = match *status {
430            ToolStatus::Pending | ToolStatus::Running => colors.tool_pending_bg,
431            ToolStatus::Failed => colors.tool_error_bg,
432            ToolStatus::Completed => colors.tool_success_bg,
433        };
434
435        // Tool header line.
436        // For args JSON we display a compact signature (e.g. `read src/foo.rs`)
437        // instead of the raw `{"path":"..."}` blob — see `parse_args_summary`.
438        // The verbose JSON still shows in the expanded args block below.
439        let chevron = if *expanded { "▾" } else { "▸" };
440        let label = display_title
441            .as_deref()
442            .map(str::to_string)
443            .unwrap_or_else(|| tools::tool_label(&name));
444        let summary = tools::parse_args_summary(&name, &args);
445        // Calculate elapsed time
446        let elapsed_str = if *status == ToolStatus::Running {
447            // Real-time elapsed while running
448            if let Some(started) = *started_at {
449                let elapsed = started.elapsed();
450                format_elapsed(elapsed)
451            } else {
452                String::new()
453            }
454        } else if *status == ToolStatus::Completed || *status == ToolStatus::Failed {
455            // Total elapsed after completion
456            if let (Some(started), Some(finished)) = (*started_at, *finished_at) {
457                let elapsed = finished.duration_since(started);
458                format_elapsed(elapsed)
459            } else {
460                String::new()
461            }
462        } else {
463            String::new()
464        };
465        let head_parts = if summary.is_empty() && elapsed_str.is_empty() {
466            format!(
467                "{} {} {}",
468                status_color.fg(status_icon),
469                colors.tool_title.fg(&bold(&label)),
470                colors.muted.fg(chevron),
471            )
472        } else if summary.is_empty() {
473            format!(
474                "{} {} {} {}",
475                status_color.fg(status_icon),
476                colors.tool_title.fg(&bold(&label)),
477                colors.muted.fg(&elapsed_str),
478                colors.muted.fg(chevron),
479            )
480        } else if elapsed_str.is_empty() {
481            format!(
482                "{} {} {} {}",
483                status_color.fg(status_icon),
484                colors.tool_title.fg(&bold(&label)),
485                colors.tool_output.fg(&summary),
486                colors.muted.fg(chevron),
487            )
488        } else {
489            format!(
490                "{} {} {} {} {}",
491                status_color.fg(status_icon),
492                colors.tool_title.fg(&bold(&label)),
493                colors.tool_output.fg(&summary),
494                colors.muted.fg(&elapsed_str),
495                colors.muted.fg(chevron),
496            )
497        };
498        // Do not sacrifice a long tool name/path summary for the running
499        // indicator. Tool calls often carry generated paths or extension
500        // names; wrap the header into panel rows so it remains inspectable.
501        let header_width = width.saturating_sub(1).max(1);
502        for part in wrap_text_with_ansi(&format!(" {}", head_parts), header_width) {
503            lines.push(apply_background_to_line(&part, width, |s| bg.bg(s)));
504        }
505
506        // Expanded: the full args JSON (indented, dim) above the result. The
507        // compact summary already lives in the header, so this is the
508        // "show me everything" path for debugging a tool call.
509        if *expanded {
510            if !args.is_empty() && args.trim() != "{}" {
511                let args_line = format!(
512                    "  {} {}",
513                    colors.muted.fg("args:"),
514                    colors.dim.fg(&pretty_args(&args))
515                );
516                for part in wrap_text_with_ansi(&args_line, width.max(1)) {
517                    lines.push(apply_background_to_line(&part, width, |s| bg.bg(s)));
518                }
519            }
520        }
521
522        // Show result if available (always — a failure's error text is the
523        // payload, not a detail to hide behind expand). Long logical lines wrap
524        // inside the panel instead of overflowing into the next terminal row.
525        if let Some(ref r) = *result {
526            let body_width = width.saturating_sub(4).max(1);
527            // Markdown results are rendered by the markdown component, which
528            // wraps and styles its own rows (tables, lists, inline code). The
529            // plain path keeps the existing naive wrap so non-markdown tool
530            // output is unchanged.
531            let visual_lines: Vec<String> = if *result_markdown {
532                crate::markdown::Markdown::new(r.clone(), 0, 0).render(body_width)
533            } else {
534                normalized_output_lines(r)
535                    .into_iter()
536                    .flat_map(|line| wrap_text_with_ansi(&line, body_width))
537                    .collect()
538            };
539            let shown = if *expanded {
540                visual_lines.len()
541            } else {
542                visual_lines.len().min(OUTPUT_PREVIEW_LINES)
543            };
544            for part in visual_lines.iter().take(shown) {
545                let result_line = if *result_markdown {
546                    // Markdown carries its own styling; drop the `│` gutter so
547                    // tables keep clean box-drawing edges.
548                    format!("  {part}")
549                } else {
550                    format!("  {} {}", colors.dim.fg("│"), colors.tool_output.fg(part))
551                };
552                let result_line = apply_background_to_line(&result_line, width, |s| bg.bg(s));
553                lines.push(result_line);
554            }
555            let hidden = visual_lines.len().saturating_sub(shown);
556            if hidden > 0 {
557                let hint = format!(
558                    "  {} … {hidden} more lines (Ctrl+O to expand)",
559                    colors.dim.fg("│")
560                );
561                let hint = colors.muted.fg(&hint);
562                lines.push(apply_background_to_line(&hint, width, |s| bg.bg(s)));
563            } else if *expanded && visual_lines.len() > OUTPUT_PREVIEW_LINES {
564                let hint = format!("  {} Ctrl+O to collapse", colors.dim.fg("│"));
565                let hint = colors.muted.fg(&hint);
566                lines.push(apply_background_to_line(&hint, width, |s| bg.bg(s)));
567            }
568        } else if diff_lines.as_ref().map_or(true, |d| d.is_empty()) && !*expanded {
569            // No result yet, no diff, and collapsed: pad one bg-tinted row so
570            // the tool block still reads as a block (pi keeps the bg band).
571            let pad = apply_background_to_line("", width, |s| bg.bg(s));
572            lines.push(pad);
573        }
574
575        // A colored diff (from `render_diff`) is shown collapsed by default
576        // (a small preview) and fully when expanded — an edit's diff is the
577        // useful payload, but dumping 40 lines into the transcript by default
578        // swamped the conversation. Cap at DIFF_LINE_CAP even when expanded.
579        if let Some(diff) = diff_lines.as_ref() {
580            let total = diff.len();
581            let cap = if *expanded {
582                DIFF_LINE_CAP
583            } else {
584                DIFF_PREVIEW_LINES
585            };
586            let shown = diff.iter().take(cap);
587            for dl in shown {
588                // Diff lines are already complete (colors + content). Indent
589                // by 2 cols so the `+`/`-` gutter lines up under the header.
590                let body = strip_ansi(dl);
591                if body.is_empty() {
592                    lines.push(String::new());
593                } else {
594                    let indented = format!("  {}", dl);
595                    lines.push(truncate_to_width(&indented, width, "…"));
596                }
597            }
598            if total > cap {
599                let more = total - cap;
600                let hint = if *expanded {
601                    format!("… {} more diff lines (Ctrl+O to collapse)", more)
602                } else {
603                    format!("… {} more diff lines (Ctrl+O to expand)", more)
604                };
605                lines.push(format!("  {}", colors.muted.fg(&hint)));
606            }
607        }
608
609        if finished {
610            if let Ok(mut cache) = self.cache.lock() {
611                *cache = Some(ToolRenderCache {
612                    width,
613                    revision,
614                    theme_revision,
615                    lines: lines.clone(),
616                });
617            }
618        } else if let Ok(mut cache) = self.cache.lock() {
619            *cache = None;
620        }
621
622        lines
623    }
624
625    fn invalidate(&self) {
626        self.bump();
627    }
628
629    fn as_any(&self) -> &dyn Any {
630        self
631    }
632}
633
634/// Render a skill-invocation box in native-Pi style.
635///
636/// Mirrors pi's `SkillInvocationMessageComponent`: a `[skill]` box on the
637/// custom-message background. Collapsed it is a single line
638/// `[skill] <name> (Ctrl+O to expand)`; expanded it shows the `[skill]` label,
639/// the bold skill name, then the full skill markdown content (the `read`
640/// result) rendered with the standard markdown component.
641fn render_skill(
642    colors: &ThemeColors,
643    name: &str,
644    content: &str,
645    expanded: bool,
646    width: usize,
647) -> Vec<String> {
648    let bg = colors.custom_message_bg;
649    let label = crate::ansi::bold("[skill]");
650    // Upstream's `Box(1, 1, bg)` — one column of horizontal padding on each
651    // side, one blank background row top and bottom.
652    let pad = " ";
653    let mut out: Vec<String> = Vec::new();
654
655    // Box top padding (pi Box(1,1)).
656    out.push(apply_background_to_line("", width, |s| bg.bg(s)));
657
658    if !expanded {
659        // Single collapsed line: `[skill] <name> (Ctrl+O to expand)`.
660        let line = format!(
661            "{pad}{} {} {}",
662            colors.custom_message_label.fg(&label),
663            colors.custom_message_text.fg(name),
664            colors.dim.fg("(Ctrl+O to expand)"),
665        );
666        out.push(apply_background_to_line(&line, width, |s| bg.bg(s)));
667        out.push(apply_background_to_line("", width, |s| bg.bg(s)));
668        return out;
669    }
670
671    // Expanded: `[skill]` label, then a single markdown block whose first
672    // paragraph is the bold skill name (upstream renders
673    // `**${name}**\n\n${content}` as one Markdown child), so the body keeps
674    // its normal markdown colors on the custom-message background.
675    out.push(apply_background_to_line(
676        &format!("{pad}{}", colors.custom_message_label.fg(&label)),
677        width,
678        |s| bg.bg(s),
679    ));
680
681    let header = format!("{pad}{}", colors.custom_message_text.fg(&bold(name)));
682    out.push(apply_background_to_line(&header, width, |s| bg.bg(s)));
683    out.push(apply_background_to_line("", width, |s| bg.bg(s)));
684
685    let body = strip_frontmatter(content);
686    if !body.is_empty() {
687        // Render the skill body with the shared markdown component. The
688        // markdown component handles code blocks / tables / headings; each
689        // line is re-tinted onto the custom-message background so the box
690        // reads as a single panel.
691        let body_width = width.saturating_sub(2).max(1);
692        let md = crate::markdown::Markdown::new(body.to_string(), 0, 0);
693        for md_line in md.render(body_width) {
694            let line = format!("{pad}{md_line}");
695            out.push(apply_background_to_line(&line, width, |s| bg.bg(s)));
696        }
697    }
698
699    // Box bottom padding (pi Box(1,1)).
700    out.push(apply_background_to_line("", width, |s| bg.bg(s)));
701    out
702}
703
704/// Strip a leading YAML frontmatter block (`---\n...\n---`) from skill file
705/// content. Upstream's skill block carries the frontmatter-stripped body
706/// (`parseSkillBlock` reads the `<skill>` payload, not the raw file), so the
707/// expanded box shows the same instructions the model received.
708fn strip_frontmatter(content: &str) -> &str {
709    let Some(rest) = content.strip_prefix("---\n") else {
710        return content;
711    };
712    let Some(end) = rest.find("\n---") else {
713        return content;
714    };
715    let after = &rest[end + 4..];
716    // Drop the blank line the closing delimiter is conventionally followed by
717    // so the markdown body starts on its first heading/paragraph.
718    after.strip_prefix('\n').unwrap_or(after)
719}
720
721/// Format a duration as a human-readable elapsed time string.
722/// Shows seconds with one decimal place for short durations,
723/// or minutes and seconds for longer durations.
724pub(crate) fn format_elapsed(duration: std::time::Duration) -> String {
725    let secs = duration.as_secs_f64();
726    if secs < 60.0 {
727        format!("{:.1}s", secs)
728    } else {
729        let mins = (secs / 60.0).floor() as u64;
730        let remaining_secs = (secs % 60.0).floor() as u64;
731        format!("{}m{}s", mins, remaining_secs)
732    }
733}
734
735/// Normalize tool output for display. Tool payloads are not guaranteed to use
736/// Unix line endings and may contain tabs/control sequences from subprocesses.
737fn normalized_output_lines(output: &str) -> Vec<String> {
738    let clean = strip_ansi(output).replace("\r\n", "\n").replace('\r', "\n");
739    clean
740        .split('\n')
741        .map(|line| {
742            let expanded = line.replace('\t', "    ");
743            expanded
744                .chars()
745                .filter(|c| *c == ' ' || !c.is_control())
746                .collect()
747        })
748        .collect()
749}
750
751/// Best-effort pretty-print of a tool args JSON blob for the expanded args
752/// block. Falls back to the raw string if it isn't valid JSON. Kept inline
753/// rather than reaching for `serde_json::to_string_pretty` so `rpi-tui` stays
754/// free of a serde dep (project constraint) — this is display-only and a
755/// compact one-liner is more useful in a terminal than a multi-line dump.
756fn pretty_args(args: &str) -> String {
757    // Compact single-line: `{"path":"src/foo.rs","offset":1}` →
758    // `path=src/foo.rs offset=1`. Strings drop quotes, numbers/bools as-is.
759    let trimmed = args.trim();
760    if trimmed.is_empty() || trimmed == "{}" {
761        return "{}".to_string();
762    }
763    // Lightweight parse: must start with `{`. If not, show raw.
764    if !trimmed.starts_with('{') {
765        return trimmed.to_string();
766    }
767    let inner = tools::flatten_json_object(trimmed);
768    if inner.is_empty() {
769        trimmed.to_string()
770    } else {
771        inner
772    }
773}
774
775/// Tool-display helpers: turn a raw tool name + args JSON into a compact,
776/// human-readable signature for the tool-block header (e.g.
777/// `read src/foo.rs`, `grep pattern · src`, `bash ls -la`).
778///
779/// Lives in `rpi-tui` (no `rpi-tools` dep) so the render crate can stay
780/// standalone; the mapping is purely lexical over the known builtin tool
781/// arg shapes. Unknown tools fall back to the raw args.
782mod tools {
783    /// A short label for the tool name in the header. Pi displays tool
784    /// names in their natural lowercase form (`read`, `edit`, …) with bold
785    /// styling, matching the original casing from the tool definition.
786    pub fn tool_label(name: &str) -> String {
787        name.to_string()
788    }
789
790    /// Parse a known builtin tool's args JSON into a compact arg summary.
791    /// Returns "" when there's nothing useful to show (e.g. unknown tool or
792    /// empty args) so the caller can fall back to a header with no summary.
793    ///
794    /// Handles both complete and incomplete (streaming) JSON — when the args
795    /// are still streaming, we fall back to a simple string search for the
796    /// key fields so the header can show the file path even before the JSON
797    /// is fully received.
798    pub fn parse_args_summary(tool: &str, args_json: &str) -> String {
799        let map = match parse_json_object(args_json) {
800            Some(m) => m,
801            None => {
802                // JSON is incomplete (streaming) — fall back to simple
803                // string extraction for the key field.
804                return extract_incomplete_args(tool, args_json);
805            }
806        };
807        let get = |k: &str| field(&map, k);
808        match tool {
809            "read" => get("path"),
810            "write" => get("path"),
811            "ls" => get("path"),
812            "find" => {
813                let p = get("pattern");
814                let dir = get("path");
815                match dir.as_str() {
816                    "" => p,
817                    d => format!("{} · {}", p, d),
818                }
819            }
820            "grep" => {
821                let p = get("pattern");
822                let path = get("path");
823                let glob = get("glob");
824                let mut parts = vec![p];
825                if !glob.is_empty() {
826                    parts.push(format!("glob {}", glob));
827                }
828                if !path.is_empty() {
829                    parts.push(path);
830                }
831                parts.join(" · ")
832            }
833            "edit" => {
834                // path + count of edits (oldText is too long to inline).
835                let path = get("path");
836                if let Some(arr) = parse_json_array_len(args_json, "edits") {
837                    if arr == 1 {
838                        path
839                    } else {
840                        format!("{} ({} edits)", path, arr)
841                    }
842                } else {
843                    path
844                }
845            }
846            "bash" => get("command"),
847            _ => {
848                // Unknown tool: show the first string-valued field if any.
849                map.into_iter().next().map(|(_, v)| v).unwrap_or_default()
850            }
851        }
852    }
853
854    /// Extract a summary from incomplete/streaming JSON by simple string matching.
855    /// This is a fallback when the JSON is not yet complete (streaming in progress).
856    /// We use regex-like pattern matching to extract key fields like "path".
857    fn extract_incomplete_args(tool: &str, args_json: &str) -> String {
858        match tool {
859            "read" | "write" | "edit" | "ls" => extract_string_field(args_json, "path"),
860            "find" => {
861                let pattern = extract_string_field(args_json, "pattern");
862                let path = extract_string_field(args_json, "path");
863                if path.is_empty() {
864                    pattern
865                } else if pattern.is_empty() {
866                    path
867                } else {
868                    format!("{} · {}", pattern, path)
869                }
870            }
871            "grep" => {
872                let pattern = extract_string_field(args_json, "pattern");
873                let path = extract_string_field(args_json, "path");
874                let glob = extract_string_field(args_json, "glob");
875                let mut parts = vec![pattern];
876                if !glob.is_empty() {
877                    parts.push(format!("glob {}", glob));
878                }
879                if !path.is_empty() {
880                    parts.push(path);
881                }
882                parts.join(" · ")
883            }
884            "bash" => extract_string_field(args_json, "command"),
885            _ => extract_any_string_field(args_json),
886        }
887    }
888
889    /// Extract a string field value from JSON using simple pattern matching.
890    /// Looks for patterns like `"key":"value"` or `"key": "value"`.
891    /// This is a best-effort extraction for incomplete/streaming JSON.
892    fn extract_string_field(json: &str, key: &str) -> String {
893        // Pattern 1: "key":"value" (no space)
894        let pattern1 = format!("\"{}\":\"", key);
895        if let Some(start) = json.find(&pattern1) {
896            let value_start = start + pattern1.len();
897            let remaining = &json[value_start..];
898            if let Some(end) = remaining.find('"') {
899                let value = &remaining[..end];
900                // Unescape common JSON escapes
901                return value
902                    .replace("\\\"", "\"")
903                    .replace("\\\\", "\\")
904                    .replace("\\n", "\n")
905                    .replace("\\t", "\t");
906            }
907        }
908
909        // Pattern 2: "key": "value" (with space)
910        let pattern2 = format!("\"{}\": \"", key);
911        if let Some(start) = json.find(&pattern2) {
912            let value_start = start + pattern2.len();
913            let remaining = &json[value_start..];
914            if let Some(end) = remaining.find('"') {
915                let value = &remaining[..end];
916                // Unescape common JSON escapes
917                return value
918                    .replace("\\\"", "\"")
919                    .replace("\\\\", "\\")
920                    .replace("\\n", "\n")
921                    .replace("\\t", "\t");
922            }
923        }
924
925        String::new()
926    }
927
928    /// Extract any string field value from incomplete JSON.
929    /// Used as a fallback for unknown tools.
930    fn extract_any_string_field(json: &str) -> String {
931        // Look for the first string value pattern: "something":"value"
932        let pattern = "\":\"";
933        if let Some(start) = json.find(pattern) {
934            let value_start = start + pattern.len();
935            let remaining = &json[value_start..];
936            if let Some(end) = remaining.find('"') {
937                let value = &remaining[..end];
938                // Unescape common JSON escapes
939                return value
940                    .replace("\\\"", "\"")
941                    .replace("\\\\", "\\")
942                    .replace("\\n", "\n")
943                    .replace("\\t", "\t");
944            }
945        }
946        String::new()
947    }
948
949    /// Look up a field's value (unquoted) in a parsed object map.
950    fn field(map: &[(String, String)], key: &str) -> String {
951        map.iter()
952            .find(|(k, _)| k == key)
953            .map(|(_, v)| unquote(v))
954            .unwrap_or_default()
955    }
956
957    /// Flatten a JSON object string into `k=v k=v` (strings unquoted).
958    /// Returns "" if the input isn't a `{...}` object or has no fields.
959    /// This is a display-only best-effort parser — it does NOT handle nested
960    /// objects/arrays (those stay as their raw JSON substring) or escapes;
961    /// it's good enough for the expanded args block where the compact header
962    /// already showed the important bit.
963    pub fn flatten_json_object(json: &str) -> String {
964        let Some(map) = parse_json_object(json) else {
965            return String::new();
966        };
967        map.into_iter()
968            .map(|(k, v)| format!("{}={}", k, unquote(&v)))
969            .collect::<Vec<_>>()
970            .join(" ")
971    }
972
973    /// Parse a flat JSON object `{"k":"v",...}` into an ordered (k,v) list.
974    /// Values are kept as their raw JSON substring (strings keep quotes;
975    /// numbers/bools are bare). Returns None if the input isn't a flat object.
976    ///
977    /// This is a tiny hand-rolled scanner — `rpi-tui` deliberately has no
978    /// serde dep. It handles the arg shapes the builtin tools emit (flat,
979    /// string/number/bool values); nested objects/arrays are returned as
980    /// their raw substring (the caller treats them opaquely).
981    fn parse_json_object(json: &str) -> Option<Vec<(String, String)>> {
982        let s = json.trim();
983        if !s.starts_with('{') || !s.ends_with('}') {
984            return None;
985        }
986        let inner = &s[1..s.len() - 1];
987        let mut out = Vec::new();
988        let mut chars = inner.chars().peekable();
989        loop {
990            skip_ws(&mut chars);
991            if chars.peek().is_none() {
992                break;
993            }
994            // key (must be a quoted string)
995            if chars.peek() != Some(&'"') {
996                return None;
997            }
998            let key = read_string(&mut chars)?;
999            skip_ws(&mut chars);
1000            if chars.next() != Some(':') {
1001                return None;
1002            }
1003            skip_ws(&mut chars);
1004            // value: string, number, true/false/null, or nested (raw).
1005            let val = read_value(&mut chars);
1006            out.push((key, val));
1007            skip_ws(&mut chars);
1008            match chars.peek() {
1009                Some(&',') => {
1010                    chars.next();
1011                }
1012                Some(_) => {
1013                    return None;
1014                } // malformed
1015                None => break,
1016            }
1017        }
1018        Some(out)
1019    }
1020
1021    /// Count the elements of a JSON array field `name` in `json`. Used by the
1022    /// edit summary to show `(N edits)` without holding the array contents.
1023    fn parse_json_array_len(json: &str, name: &str) -> Option<usize> {
1024        // Find `"name":` then the `[...]` that follows.
1025        let pat = format!("\"{}\":", name);
1026        let idx = json.find(&pat)?;
1027        let rest = &json[idx + pat.len()..];
1028        let rest = rest.trim_start();
1029        if !rest.starts_with('[') {
1030            return None;
1031        }
1032        // Scan the array body respecting nested brackets + strings.
1033        let mut depth = 0isize;
1034        let mut in_str = false;
1035        let mut esc = false;
1036        let mut count = 0usize;
1037        let mut saw_any = false;
1038        for c in rest.chars() {
1039            if in_str {
1040                if esc {
1041                    esc = false;
1042                } else if c == '\\' {
1043                    esc = true;
1044                } else if c == '"' {
1045                    in_str = false;
1046                }
1047                continue;
1048            }
1049            match c {
1050                '"' => in_str = true,
1051                '[' | '{' => depth += 1,
1052                ']' | '}' => {
1053                    depth -= 1;
1054                    if depth == 0 {
1055                        break;
1056                    }
1057                }
1058                ',' if depth == 1 => count += 1,
1059                _ if !c.is_whitespace() && depth >= 1 => saw_any = true,
1060                _ => {}
1061            }
1062        }
1063        if !saw_any {
1064            Some(0)
1065        } else {
1066            Some(count + 1)
1067        }
1068    }
1069
1070    fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars>) {
1071        while let Some(&c) = chars.peek() {
1072            if c.is_whitespace() {
1073                chars.next();
1074            } else {
1075                break;
1076            }
1077        }
1078    }
1079
1080    fn read_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
1081        if chars.next()? != '"' {
1082            return None;
1083        }
1084        let mut s = String::new();
1085        let mut esc = false;
1086        while let Some(c) = chars.next() {
1087            if esc {
1088                esc = false;
1089                s.push(c);
1090                continue;
1091            }
1092            match c {
1093                '\\' => esc = true,
1094                '"' => return Some(s),
1095                _ => s.push(c),
1096            }
1097        }
1098        None
1099    }
1100
1101    fn read_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
1102        match chars.peek() {
1103            Some(&'"') => {
1104                // Quoted string — include the quotes in the raw value (so the
1105                // caller can distinguish strings from numbers); `unquote`
1106                // strips them for display.
1107                let mut s = String::from("\"");
1108                chars.next();
1109                let mut esc = false;
1110                while let Some(c) = chars.next() {
1111                    s.push(c);
1112                    if esc {
1113                        esc = false;
1114                        continue;
1115                    }
1116                    if c == '\\' {
1117                        esc = true;
1118                        continue;
1119                    }
1120                    if c == '"' {
1121                        break;
1122                    }
1123                }
1124                s
1125            }
1126            Some(&'[') | Some(&'{') => read_nested(chars),
1127            _ => {
1128                // Bare token (number/true/false/null) — read until , or }.
1129                let mut s = String::new();
1130                while let Some(&c) = chars.peek() {
1131                    if c == ',' || c == '}' {
1132                        break;
1133                    }
1134                    s.push(c);
1135                    chars.next();
1136                }
1137                s.trim().to_string()
1138            }
1139        }
1140    }
1141
1142    fn read_nested(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
1143        // Copy the raw substring for a nested array/object, tracking depth +
1144        // string state so commas inside don't terminate it early.
1145        let mut s = String::new();
1146        let mut depth = 0isize;
1147        let mut in_str = false;
1148        let mut esc = false;
1149        while let Some(c) = chars.next() {
1150            s.push(c);
1151            if in_str {
1152                if esc {
1153                    esc = false;
1154                } else if c == '\\' {
1155                    esc = true;
1156                } else if c == '"' {
1157                    in_str = false;
1158                }
1159                continue;
1160            }
1161            match c {
1162                '"' => in_str = true,
1163                '[' | '{' => depth += 1,
1164                ']' | '}' => {
1165                    depth -= 1;
1166                    if depth == 0 {
1167                        break;
1168                    }
1169                }
1170                _ => {}
1171            }
1172        }
1173        s
1174    }
1175
1176    fn unquote(v: &str) -> String {
1177        if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
1178            v[1..v.len() - 1].to_string()
1179        } else {
1180            v.to_string()
1181        }
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    #[test]
1188    fn plan_panel_keeps_markdown_and_hides_raw_arguments() {
1189        use super::*;
1190        let panel = ToolExecutionComponent::new("plan_mode_complete", "RAW_PLAN_ARGUMENT");
1191        panel.set_result("Plan complete. Normal tool access restored.", false);
1192        panel.set_plan_state(
1193            "## Implementation\n\n1. Inspect code\n2. Update UI\n\n## Validation\n\nRun tests"
1194                .into(),
1195            false,
1196        );
1197        panel.set_expanded(true);
1198        let rendered = strip_ansi(&panel.render(60).join("\n"));
1199        assert!(rendered.contains("Plan · Ready"));
1200        assert!(rendered.contains("Implementation"));
1201        assert!(rendered.contains("Validation"));
1202        assert!(rendered.contains("Inspect code"));
1203        assert!(rendered.contains("Update UI"));
1204        assert!(!rendered.contains("RAW_PLAN_ARGUMENT"));
1205        assert!(!rendered.contains("args:"));
1206        assert!(!rendered.contains("Normal tool access"));
1207        panel.set_plan_state(String::new(), false);
1208        assert!(strip_ansi(&panel.render(60).join("\n")).contains("Plan · Idle"));
1209    }
1210
1211    #[test]
1212    fn completed_plan_can_expand_to_the_full_body() {
1213        use super::*;
1214        let panel = ToolExecutionComponent::new("plan_mode_complete", "{}");
1215        panel.set_result("", false);
1216        panel.set_plan_state(
1217            (1..=30)
1218                .map(|i| format!("{i}. Step {i}"))
1219                .collect::<Vec<_>>()
1220                .join("\n"),
1221            false,
1222        );
1223        let preview = strip_ansi(&panel.render(60).join("\n"));
1224        assert!(preview.contains("more lines"));
1225        assert!(!preview.contains("Step 30"));
1226        panel.set_expanded(true);
1227        let full = strip_ansi(&panel.render(60).join("\n"));
1228        assert!(full.contains("Step 30"));
1229    }
1230    #[test]
1231    fn task_list_wraps_and_distinguishes_statuses() {
1232        use super::*;
1233        let panel = ToolExecutionComponent::new("todo", "{}");
1234        panel.set_result("ignored summary", false);
1235        panel.set_todo_state(vec![
1236            ("Finished task".into(), "completed".into()),
1237            ("正在处理一个很长的任务名称".into(), "in_progress".into()),
1238            ("Next task".into(), "pending".into()),
1239        ]);
1240        let rows = panel.render(24);
1241        let rendered = strip_ansi(&rows.join("\n"));
1242        assert!(rendered.contains("1/3 completed"));
1243        assert!(rendered.contains("[✓] Finished task"));
1244        assert!(rendered.contains("[•]"));
1245        assert!(rendered.contains("[ ] Next task"));
1246        assert!(rows.iter().any(|line| line.contains("\x1b[9m")));
1247        assert!(!rendered.contains("ignored summary"));
1248        assert!(!rendered.contains("args:"));
1249        for line in rows {
1250            assert!(crate::utils::visible_width(&line) <= 24);
1251        }
1252    }
1253    use super::*;
1254
1255    #[test]
1256    fn test_tool_execution_basic() {
1257        let tool = ToolExecutionComponent::new("read", "file.txt");
1258        let lines = tool.render(80);
1259        assert!(!lines.is_empty());
1260    }
1261
1262    #[test]
1263    fn test_tool_execution_status() {
1264        let tool = ToolExecutionComponent::new("bash", "ls -la");
1265
1266        assert_eq!(tool.status(), ToolStatus::Pending);
1267
1268        tool.set_running();
1269        assert_eq!(tool.status(), ToolStatus::Running);
1270
1271        tool.set_result("output", false);
1272        assert_eq!(tool.status(), ToolStatus::Completed);
1273    }
1274
1275    #[test]
1276    fn test_tool_execution_expanded() {
1277        let tool = ToolExecutionComponent::new("edit", "file.rs");
1278        tool.set_expanded(true);
1279        tool.set_result("success", false);
1280
1281        let lines = tool.render(80);
1282        assert!(lines.len() > 1); // Should have more lines when expanded
1283    }
1284
1285    #[test]
1286    fn test_tool_execution_error() {
1287        let tool = ToolExecutionComponent::new("bash", "invalid_command");
1288        tool.set_result("command not found", true);
1289
1290        let lines = tool.render(80);
1291        let joined = lines.join("\n");
1292        // Failed state: error-colored ✗ glyph + the result text echoed.
1293        assert!(
1294            joined.contains("command not found"),
1295            "result text missing: {joined}"
1296        );
1297        assert!(joined.contains('✗'), "error glyph missing: {joined}");
1298    }
1299
1300    #[test]
1301    fn long_tool_header_wraps_instead_of_truncating() {
1302        let title = "A VERY LONG EXTENSION TOOL NAME";
1303        let tool = ToolExecutionComponent::new("extension_tool", "{}");
1304        tool.set_display_title(title);
1305        tool.set_running();
1306        let lines = tool.render(16);
1307        let rendered = crate::ansi::strip_ansi(&lines.join("\n"));
1308        let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect();
1309        let expected: String = title.chars().filter(|ch| !ch.is_whitespace()).collect();
1310        assert!(
1311            compact.contains(&expected),
1312            "header was truncated: {rendered}"
1313        );
1314        assert!(lines
1315            .iter()
1316            .all(|line| crate::utils::visible_width(line) <= 16));
1317    }
1318
1319    #[test]
1320    fn tool_output_wraps_with_a_stable_gutter_and_background() {
1321        let tool = ToolExecutionComponent::new("read", r#"{"path":"wide.txt"}"#);
1322        tool.set_result("0123456789ABCDEFGHIJ", false);
1323        let rendered = tool.render(12);
1324        let body: Vec<String> = rendered
1325            .iter()
1326            .map(|line| crate::ansi::strip_ansi(line))
1327            .filter(|line| line.contains('│'))
1328            .collect();
1329        assert!(body.len() >= 2, "long output should wrap: {body:?}");
1330        assert!(body.iter().all(|line| line.starts_with("  │ ")));
1331        assert!(rendered
1332            .iter()
1333            .all(|line| crate::utils::visible_width(line) <= 12));
1334        // Nested foreground resets must re-apply the panel background.
1335        assert!(rendered
1336            .iter()
1337            .any(|line| line.matches("\x1b[48;2;38;48;52m").count() > 1));
1338    }
1339
1340    #[test]
1341    fn markdown_result_renders_tables_instead_of_literal_pipes() {
1342        let tool = ToolExecutionComponent::new("todo", r#"{"action":"list"}"#);
1343        tool.set_result_markdown(true);
1344        tool.set_result(
1345            "**Todos** | 1 done\n\n| ID | Task |\n|----|------|\n| #1 | ship |",
1346            false,
1347        );
1348        let rendered = crate::ansi::strip_ansi(&tool.render(60).join("\n"));
1349        // The markdown component lays the table out with box-drawing borders
1350        // instead of the raw `| a | b |` source.
1351        assert!(
1352            rendered.contains('┌') && rendered.contains('│'),
1353            "table not rendered: {rendered}"
1354        );
1355        assert!(
1356            !rendered.contains("| ID |"),
1357            "raw markdown table leaked: {rendered}"
1358        );
1359        assert!(
1360            !rendered.contains("**Todos**"),
1361            "raw bold marker leaked: {rendered}"
1362        );
1363        assert!(rendered.contains("Todos"), "text missing: {rendered}");
1364
1365        // The default (non-markdown) mode must keep rendering the same body as
1366        // plain text with the `│` gutter.
1367        let plain = ToolExecutionComponent::new("todo", r#"{"action":"list"}"#);
1368        plain.set_result("| ID | Task |", false);
1369        let plain_rendered = crate::ansi::strip_ansi(&plain.render(60).join("\n"));
1370        assert!(
1371            plain_rendered.contains("| ID | Task |"),
1372            "plain tools must not be markdown-rendered: {plain_rendered}"
1373        );
1374    }
1375
1376    #[test]
1377    fn long_tool_output_collapses_and_ctrl_o_expands() {
1378        let tool = ToolExecutionComponent::new("read", r#"{"path":"large.rs"}"#);
1379        let output = (1..=20)
1380            .map(|line| format!("line {line}"))
1381            .collect::<Vec<_>>()
1382            .join("\n");
1383        tool.set_result(&output, false);
1384
1385        let collapsed = tool.render(80);
1386        let collapsed_plain = crate::ansi::strip_ansi(&collapsed.join("\n"));
1387        assert!(collapsed_plain.contains("8 more lines (Ctrl+O to expand)"));
1388        assert!(!collapsed_plain.contains("line 20"));
1389
1390        tool.set_expanded(true);
1391        let expanded = tool.render(80);
1392        let expanded_plain = crate::ansi::strip_ansi(&expanded.join("\n"));
1393        assert!(expanded_plain.contains("line 20"));
1394        assert!(expanded_plain.contains("Ctrl+O to collapse"));
1395    }
1396
1397    /// The header summarizes the args JSON into a compact signature instead
1398    /// of dumping the raw `{"path":"..."}` blob. Each builtin tool maps its
1399    /// key fields to a one-liner.
1400    /// A `read` of a `SKILL.md` renders as upstream's `[skill] <name>`
1401    /// invocation box: custom-message background, one collapsed line with an
1402    /// expand hint, and (when expanded) the frontmatter-stripped skill body.
1403    #[test]
1404    fn skill_invocation_renders_native_style_box() {
1405        let tool = ToolExecutionComponent::new("read", r#"{"path":"/skills/release/SKILL.md"}"#);
1406        tool.set_skill_name("release");
1407        tool.set_result(
1408            "---\nname: release\ndescription: cut a release\n---\n\n# Steps\n\nDo the thing.",
1409            false,
1410        );
1411
1412        let collapsed = tool.render(80);
1413        let plain = crate::ansi::strip_ansi(&collapsed.join("\n"));
1414        assert!(
1415            plain.contains("[skill] release"),
1416            "collapsed label: {plain}"
1417        );
1418        assert!(plain.contains("(Ctrl+O to expand)"), "expand hint: {plain}");
1419        assert!(!plain.contains("Steps"), "collapsed hides body: {plain}");
1420        // Uses the custom-message background (dark: #2d2838), not a tool tint.
1421        assert!(
1422            collapsed.iter().any(|l| l.contains("\x1b[48;2;45;40;56m")),
1423            "custom-message bg missing: {collapsed:?}"
1424        );
1425
1426        tool.set_expanded(true);
1427        let expanded = tool.render(80);
1428        let plain = crate::ansi::strip_ansi(&expanded.join("\n"));
1429        assert!(plain.contains("[skill]"), "expanded label: {plain}");
1430        assert!(plain.contains("release"), "expanded name: {plain}");
1431        assert!(
1432            plain.contains("Do the thing."),
1433            "expanded body missing: {plain}"
1434        );
1435        // Frontmatter is stripped (upstream shows the parsed skill body only).
1436        assert!(
1437            !plain.contains("name: release"),
1438            "frontmatter leaked: {plain}"
1439        );
1440    }
1441
1442    #[test]
1443    fn strip_frontmatter_only_strips_a_leading_block() {
1444        assert_eq!(strip_frontmatter("plain"), "plain");
1445        assert_eq!(strip_frontmatter("---\nname: x\n---\nbody"), "body");
1446        // No opening delimiter → untouched.
1447        assert_eq!(strip_frontmatter("a\n---\nb"), "a\n---\nb");
1448    }
1449
1450    #[test]
1451    fn test_tool_header_args_summary() {
1452        // `read` → just the path.
1453        let t = ToolExecutionComponent::new("read", r#"{"path":"src/foo.rs"}"#);
1454        let joined = t.render(80).join("\n");
1455        let plain = crate::ansi::strip_ansi(&joined);
1456        assert!(plain.contains("read"), "tool label lowercase: {plain}");
1457        assert!(plain.contains("src/foo.rs"), "path in summary: {plain}");
1458        // The raw JSON must NOT leak into the header.
1459        assert!(!plain.contains("\"path\""), "raw JSON leaked: {plain}");
1460
1461        // `grep` → pattern · path (no path field → just pattern).
1462        let t = ToolExecutionComponent::new("grep", r#"{"pattern":"TODO","path":"src"}"#);
1463        let plain = crate::ansi::strip_ansi(&t.render(80).join("\n"));
1464        assert!(plain.contains("TODO · src"), "grep summary: {plain}");
1465
1466        // `edit` with multiple edits → `(N edits)` suffix.
1467        let t = ToolExecutionComponent::new(
1468            "edit",
1469            r#"{"path":"a.rs","edits":[{"oldText":"x"},{"oldText":"y"}]}"#,
1470        );
1471        let plain = crate::ansi::strip_ansi(&t.render(80).join("\n"));
1472        assert!(plain.contains("a.rs (2 edits)"), "edit count: {plain}");
1473    }
1474
1475    /// Unknown tools fall back to the first string-valued arg field rather
1476    /// than showing nothing — keeps the header useful for plugin tools whose
1477    /// arg shapes the render crate doesn't know.
1478    #[test]
1479    fn test_tool_header_unknown_tool_falls_back() {
1480        let t = ToolExecutionComponent::new("mytool", r#"{"query":"hai"}"#);
1481        let plain = crate::ansi::strip_ansi(&t.render(80).join("\n"));
1482        assert!(plain.contains("hai"), "unknown tool summary: {plain}");
1483    }
1484
1485    /// The collapsed diff preview caps at DIFF_PREVIEW_LINES and teases the
1486    /// remaining count; expanding lifts the cap to DIFF_LINE_CAP.
1487    #[test]
1488    fn test_tool_diff_preview_then_expand() {
1489        // Build a diff with 12 changed lines — over the 6-line preview, under
1490        // the 40-line expanded cap.
1491        let mut diff = String::new();
1492        for i in 1..=6 {
1493            diff.push_str(&format!("-{} old line {}\n", i, i));
1494            diff.push_str(&format!("+{} new line {}\n", i, i));
1495        }
1496        let t = ToolExecutionComponent::new("edit", r#"{"path":"f"}"#);
1497        t.set_result("ok", false);
1498        t.set_diff(crate::diff::render_diff(&diff, 80));
1499
1500        let collapsed = t.render(80);
1501        // Preview cap = 6 lines + 1 header + 1 result + 1 hint.
1502        let diff_body = collapsed
1503            .iter()
1504            .filter(|l| {
1505                let p = crate::ansi::strip_ansi(l);
1506                p.trim_start().starts_with('-') || p.trim_start().starts_with('+')
1507            })
1508            .count();
1509        assert_eq!(
1510            diff_body, 6,
1511            "collapsed preview should cap at 6: {collapsed:?}"
1512        );
1513        let hint = collapsed
1514            .iter()
1515            .map(|l| crate::ansi::strip_ansi(l))
1516            .find(|l| l.contains("more diff lines"));
1517        assert!(hint.is_some(), "preview hint missing");
1518        assert!(
1519            hint.as_ref().unwrap().contains("Ctrl+O to expand"),
1520            "hint text: {hint:?}"
1521        );
1522
1523        t.set_expanded(true);
1524        let expanded = t.render(80);
1525        let diff_body = expanded
1526            .iter()
1527            .filter(|l| {
1528                let p = crate::ansi::strip_ansi(l);
1529                p.trim_start().starts_with('-') || p.trim_start().starts_with('+')
1530            })
1531            .count();
1532        assert_eq!(diff_body, 12, "expanded should show all 12: {expanded:?}");
1533    }
1534}