1use 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
16const DIFF_LINE_CAP: usize = 40;
18const DIFF_PREVIEW_LINES: usize = 6;
22const OUTPUT_PREVIEW_LINES: usize = 12;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ToolStatus {
30 Pending,
32 Running,
34 Completed,
36 Failed,
38}
39
40pub struct ToolExecutionComponent {
44 name: Mutex<String>,
46 display_title: Mutex<Option<String>>,
49 skill_name: Mutex<Option<String>>,
54 args: Mutex<String>,
56 result: Mutex<Option<String>>,
58 status: Mutex<ToolStatus>,
60 started_at: Mutex<Option<Instant>>,
62 finished_at: Mutex<Option<Instant>>,
64 expanded: Mutex<bool>,
66 diff_lines: Mutex<Option<Vec<String>>>,
70 result_markdown: Mutex<bool>,
75 todo_state: Mutex<Option<Vec<(String, String)>>>,
77 plan_state: Mutex<Option<(String, bool)>>,
78 revision: AtomicU64,
82 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 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 fn bump(&self) {
121 self.revision.fetch_add(1, Ordering::AcqRel);
122 }
123
124 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 pub fn set_result(&self, result: &str, is_error: bool) {
134 if let Ok(mut r) = self.result.lock() {
135 *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 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 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 pub fn is_expanded(&self) -> bool {
174 *self.expanded.lock().unwrap()
175 }
176
177 pub fn status(&self) -> ToolStatus {
179 *self.status.lock().unwrap()
180 }
181
182 pub fn name(&self) -> String {
184 self.name.lock().unwrap().clone()
185 }
186
187 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 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 pub fn is_skill(&self) -> bool {
209 self.skill_name.lock().map(|s| s.is_some()).unwrap_or(false)
210 }
211
212 pub fn skill_name(&self) -> Option<String> {
214 self.skill_name.lock().unwrap().clone()
215 }
216
217 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 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 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 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 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 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 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 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 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 let elapsed_str = if *status == ToolStatus::Running {
447 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 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 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 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 if let Some(ref r) = *result {
526 let body_width = width.saturating_sub(4).max(1);
527 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 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 let pad = apply_background_to_line("", width, |s| bg.bg(s));
572 lines.push(pad);
573 }
574
575 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 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
634fn 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 let pad = " ";
653 let mut out: Vec<String> = Vec::new();
654
655 out.push(apply_background_to_line("", width, |s| bg.bg(s)));
657
658 if !expanded {
659 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 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 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 out.push(apply_background_to_line("", width, |s| bg.bg(s)));
701 out
702}
703
704fn 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 after.strip_prefix('\n').unwrap_or(after)
719}
720
721pub(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
735fn 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
751fn pretty_args(args: &str) -> String {
757 let trimmed = args.trim();
760 if trimmed.is_empty() || trimmed == "{}" {
761 return "{}".to_string();
762 }
763 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
775mod tools {
783 pub fn tool_label(name: &str) -> String {
787 name.to_string()
788 }
789
790 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 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 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 map.into_iter().next().map(|(_, v)| v).unwrap_or_default()
850 }
851 }
852 }
853
854 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 fn extract_string_field(json: &str, key: &str) -> String {
893 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 return value
902 .replace("\\\"", "\"")
903 .replace("\\\\", "\\")
904 .replace("\\n", "\n")
905 .replace("\\t", "\t");
906 }
907 }
908
909 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 return value
918 .replace("\\\"", "\"")
919 .replace("\\\\", "\\")
920 .replace("\\n", "\n")
921 .replace("\\t", "\t");
922 }
923 }
924
925 String::new()
926 }
927
928 fn extract_any_string_field(json: &str) -> String {
931 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 return value
940 .replace("\\\"", "\"")
941 .replace("\\\\", "\\")
942 .replace("\\n", "\n")
943 .replace("\\t", "\t");
944 }
945 }
946 String::new()
947 }
948
949 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 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 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 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 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 } None => break,
1016 }
1017 }
1018 Some(out)
1019 }
1020
1021 fn parse_json_array_len(json: &str, name: &str) -> Option<usize> {
1024 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 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 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 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 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); }
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 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 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 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 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 #[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 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 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 assert_eq!(strip_frontmatter("a\n---\nb"), "a\n---\nb");
1448 }
1449
1450 #[test]
1451 fn test_tool_header_args_summary() {
1452 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 assert!(!plain.contains("\"path\""), "raw JSON leaked: {plain}");
1460
1461 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 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 #[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 #[test]
1488 fn test_tool_diff_preview_then_expand() {
1489 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 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}