1use imp_core::config::{AnimationLevel, SidebarStyle, ToolOutputDisplay, UiConfig};
2use ratatui::buffer::Buffer;
3use ratatui::layout::Rect;
4use ratatui::text::{Line, Span};
5use ratatui::widgets::Widget;
6use serde_json::Value;
7
8use crate::highlight::Highlighter;
9use crate::selection::TextSurface;
10use crate::theme::Theme;
11use crate::views::tool_output::{styled_tool_output_lines, wrap_styled_lines};
12use crate::views::tools::DisplayToolCall;
13
14#[derive(Debug, Clone)]
15pub struct SidebarDetailRenderData {
16 pub lines: Vec<Line<'static>>,
17 pub plain_lines: Vec<String>,
18}
19
20#[derive(Default)]
24pub struct Sidebar {
25 pub open: bool,
27 pub list_scroll: usize,
29 pub detail_scroll: usize,
31 pub first_tool_seen: bool,
33 pub list_height: u16,
35}
36
37impl Sidebar {
38 pub fn reset_detail_scroll(&mut self) {
40 self.detail_scroll = 0;
41 }
42
43 pub fn scroll_list_up(&mut self, n: usize) {
45 self.list_scroll = self.list_scroll.saturating_sub(n);
46 }
47
48 pub fn scroll_list_down(&mut self, n: usize) {
50 self.list_scroll += n;
51 }
52
53 pub fn scroll_detail_up(&mut self, n: usize) {
55 self.detail_scroll = self.detail_scroll.saturating_sub(n);
56 }
57
58 pub fn scroll_detail_down(&mut self, n: usize) {
60 self.detail_scroll += n;
61 }
62
63 pub fn ensure_selected_visible(&mut self, selected: usize) {
65 let visible = (self.list_height as usize).max(1);
66 if selected < self.list_scroll {
67 self.list_scroll = selected;
68 } else if selected >= self.list_scroll + visible {
69 self.list_scroll = selected.saturating_sub(visible.saturating_sub(1));
70 }
71 }
72}
73
74pub fn sidebar_sub_areas(
81 sidebar_area: Rect,
82 tool_count: usize,
83 style: SidebarStyle,
84) -> (Rect, Rect) {
85 let content = Rect {
86 x: sidebar_area.x + 2,
87 y: sidebar_area.y,
88 width: sidebar_area.width.saturating_sub(2),
89 height: sidebar_area.height,
90 };
91
92 match style {
93 SidebarStyle::Inspector => {
94 let full = Rect {
95 x: sidebar_area.x,
96 width: sidebar_area.width,
97 ..content
98 };
99 (full, full)
100 }
101 SidebarStyle::Stream => {
102 let full = Rect {
104 x: sidebar_area.x,
105 width: sidebar_area.width,
106 ..content
107 };
108 let empty = Rect {
109 x: sidebar_area.x,
110 width: sidebar_area.width,
111 y: sidebar_area.y + sidebar_area.height,
112 height: 0,
113 };
114 (full, empty)
115 }
116 SidebarStyle::Split => {
117 let (list_area, _, detail_area) = compute_split(content, tool_count);
118 (
119 Rect {
120 x: sidebar_area.x,
121 width: sidebar_area.width,
122 y: list_area.y,
123 height: list_area.height,
124 },
125 Rect {
126 x: sidebar_area.x,
127 width: sidebar_area.width,
128 y: detail_area.y,
129 height: detail_area.height,
130 },
131 )
132 }
133 }
134}
135
136fn compute_split(content: Rect, tool_count: usize) -> (Rect, Option<u16>, Rect) {
138 let h = content.height as usize;
139 let min_detail = 3;
140 let sep = 1;
141 let min_total = 2 + sep + min_detail;
142
143 if h < min_total || tool_count == 0 {
144 return (
145 content,
146 None,
147 Rect {
148 x: content.x,
149 y: content.y + content.height,
150 width: content.width,
151 height: 0,
152 },
153 );
154 }
155
156 let max_list = (h * 40 / 100).max(2);
157 let available_for_list = h.saturating_sub(sep + min_detail);
158 let desired = tool_count.clamp(2, max_list);
159 let list_h = desired.min(available_for_list).max(2);
160 let detail_h = h.saturating_sub(list_h + sep);
161
162 let list_area = Rect {
163 height: list_h as u16,
164 ..content
165 };
166 let sep_y = content.y + list_h as u16;
167 let detail_area = Rect {
168 y: sep_y + sep as u16,
169 height: detail_h as u16,
170 ..content
171 };
172
173 (list_area, Some(sep_y), detail_area)
174}
175
176pub struct SidebarView<'a> {
180 tool_calls: Vec<&'a DisplayToolCall>,
181 selected: Option<usize>,
182 theme: &'a Theme,
183 highlighter: &'a Highlighter,
184 tick: u64,
185 list_scroll: usize,
186 detail_scroll: usize,
187 ui_config: &'a UiConfig,
188 precomputed_stream_lines: Option<&'a [Line<'static>]>,
189 precomputed_detail_lines: Option<&'a [Line<'static>]>,
190}
191
192impl<'a> SidebarView<'a> {
193 pub fn new(
194 tool_calls: Vec<&'a DisplayToolCall>,
195 selected: Option<usize>,
196 theme: &'a Theme,
197 highlighter: &'a Highlighter,
198 tick: u64,
199 list_scroll: usize,
200 detail_scroll: usize,
201 ui_config: &'a UiConfig,
202 ) -> Self {
203 Self {
204 tool_calls,
205 selected,
206 theme,
207 highlighter,
208 tick,
209 list_scroll,
210 detail_scroll,
211 ui_config,
212 precomputed_stream_lines: None,
213 precomputed_detail_lines: None,
214 }
215 }
216
217 pub fn precomputed_stream_lines(mut self, lines: &'a [Line<'static>]) -> Self {
218 self.precomputed_stream_lines = Some(lines);
219 self
220 }
221
222 pub fn precomputed_detail_lines(mut self, lines: &'a [Line<'static>]) -> Self {
223 self.precomputed_detail_lines = Some(lines);
224 self
225 }
226}
227
228impl Widget for SidebarView<'_> {
229 fn render(self, area: Rect, buf: &mut Buffer) {
230 if area.width < 3 || area.height < 2 {
231 return;
232 }
233
234 let border_style = self.theme.border_style();
236 for y in area.y..area.y + area.height {
237 if let Some(cell) = buf.cell_mut((area.x, y)) {
238 cell.set_symbol("│");
239 cell.set_style(border_style);
240 }
241 }
242
243 let cx = area.x + 2;
244 let cw = area.width.saturating_sub(2);
245 if cw == 0 {
246 return;
247 }
248 let content = Rect {
249 x: cx,
250 y: area.y,
251 width: cw,
252 height: area.height,
253 };
254
255 if self.tool_calls.is_empty() {
256 let line = Line::from(Span::styled("No tool calls", self.theme.muted_style()));
257 buf.set_line(cx, area.y, &line, cw);
258 return;
259 }
260
261 match self.ui_config.sidebar_style {
262 SidebarStyle::Inspector => {
263 let selected_tc = self.selected.and_then(|i| self.tool_calls.get(i)).copied();
264 if let Some(lines) = self.precomputed_detail_lines {
265 render_detail_from_lines(lines, self.theme, self.detail_scroll, content, buf);
266 } else {
267 render_detail(
268 selected_tc,
269 self.theme,
270 self.highlighter,
271 self.detail_scroll,
272 self.ui_config,
273 content,
274 buf,
275 );
276 }
277 }
278 SidebarStyle::Stream => {
279 if let Some(lines) = self.precomputed_stream_lines {
280 render_stream_from_lines(lines, self.theme, self.detail_scroll, content, buf);
281 } else {
282 render_stream(
283 &self.tool_calls,
284 self.selected,
285 self.theme,
286 self.highlighter,
287 self.tick,
288 self.detail_scroll,
289 self.ui_config,
290 content,
291 buf,
292 self.ui_config.animations,
293 );
294 }
295 }
296 SidebarStyle::Split => {
297 let (list_area, sep_y, detail_area) = compute_split(content, self.tool_calls.len());
298
299 render_list(
300 &self.tool_calls,
301 self.selected,
302 self.theme,
303 self.tick,
304 self.list_scroll,
305 list_area,
306 buf,
307 self.ui_config.animations,
308 );
309
310 if let Some(sy) = sep_y {
311 let sep: String = "─".repeat(cw as usize);
312 buf.set_line(cx, sy, &Line::from(Span::styled(sep, border_style)), cw);
313 }
314
315 let selected_tc = self.selected.and_then(|i| self.tool_calls.get(i)).copied();
316 if let Some(lines) = self.precomputed_detail_lines {
317 render_detail_from_lines(
318 lines,
319 self.theme,
320 self.detail_scroll,
321 detail_area,
322 buf,
323 );
324 } else {
325 render_detail(
326 selected_tc,
327 self.theme,
328 self.highlighter,
329 self.detail_scroll,
330 self.ui_config,
331 detail_area,
332 buf,
333 );
334 }
335 }
336 }
337 }
338}
339
340fn render_scrolled_lines(lines: &[Line<'_>], area: Rect, buf: &mut Buffer, scroll: usize) -> usize {
343 let total = lines.len();
344 let visible = area.height as usize;
345 let start = scroll.min(total.saturating_sub(visible));
346
347 for (i, line) in lines.iter().skip(start).take(visible).enumerate() {
348 let row = area.y + i as u16;
349 buf.set_line(area.x, row, line, area.width);
350 }
351
352 total
353}
354
355pub fn build_stream_lines(
356 tool_calls: &[&DisplayToolCall],
357 selected: Option<usize>,
358 theme: &Theme,
359 highlighter: &Highlighter,
360 tick: u64,
361 ui_config: &UiConfig,
362 animation_level: AnimationLevel,
363 width: usize,
364) -> Vec<Line<'static>> {
365 let mut all_lines: Vec<Line<'static>> = Vec::new();
366
367 for (idx, tc) in tool_calls.iter().enumerate() {
368 let focused = selected == Some(idx);
369 let header = tc.header_line_animated_focused(theme, tick, focused, animation_level);
370 all_lines.push(header);
371
372 let output_lines = styled_output_lines(tc, ui_config, highlighter, theme, width);
373 for line in output_lines {
374 all_lines.push(indent_line(line));
375 }
376
377 if idx + 1 < tool_calls.len() {
378 all_lines.push(Line::raw(""));
379 }
380 }
381
382 all_lines
383}
384
385pub fn render_stream_from_lines(
386 lines: &[Line<'_>],
387 theme: &Theme,
388 scroll: usize,
389 area: Rect,
390 buf: &mut Buffer,
391) {
392 let total = render_scrolled_lines(lines, area, buf, scroll);
393 let visible = area.height as usize;
394 let start = scroll.min(total.saturating_sub(visible));
395
396 if total > visible && visible > 0 {
397 let pct = ((start + visible).min(total) * 100) / total;
398 let indicator = format!(" {pct}% ");
399 let iw = indicator.len() as u16;
400 if area.width > iw {
401 let ix = area.x + area.width - iw;
402 let iy = area.y + area.height.saturating_sub(1);
403 buf.set_line(
404 ix,
405 iy,
406 &Line::from(Span::styled(indicator, theme.muted_style())),
407 iw,
408 );
409 }
410 }
411}
412
413#[allow(clippy::too_many_arguments)]
416fn render_stream(
417 tool_calls: &[&DisplayToolCall],
418 selected: Option<usize>,
419 theme: &Theme,
420 highlighter: &Highlighter,
421 tick: u64,
422 scroll: usize,
423 ui_config: &UiConfig,
424 area: Rect,
425 buf: &mut Buffer,
426 animation_level: AnimationLevel,
427) {
428 if area.height == 0 || area.width == 0 {
429 return;
430 }
431
432 let width = area.width as usize;
433 let all_lines = build_stream_lines(
434 tool_calls,
435 selected,
436 theme,
437 highlighter,
438 tick,
439 ui_config,
440 animation_level,
441 width,
442 );
443
444 render_stream_from_lines(&all_lines, theme, scroll, area, buf);
445}
446
447fn render_list(
450 tool_calls: &[&DisplayToolCall],
451 selected: Option<usize>,
452 theme: &Theme,
453 tick: u64,
454 scroll: usize,
455 area: Rect,
456 buf: &mut Buffer,
457 animation_level: AnimationLevel,
458) {
459 if area.height == 0 || area.width == 0 {
460 return;
461 }
462
463 let visible = area.height as usize;
464 let total = tool_calls.len();
465 let start = scroll.min(total.saturating_sub(visible));
466
467 for (i, tc) in tool_calls.iter().skip(start).take(visible).enumerate() {
468 let idx = start + i;
469 let focused = selected == Some(idx);
470 let row = area.y + i as u16;
471 let header = tc.header_line_animated_focused(theme, tick, focused, animation_level);
472 buf.set_line(area.x, row, &header, area.width);
473 }
474
475 if total > visible && visible > 0 {
476 let pct = ((start + visible).min(total) * 100) / total;
477 let indicator = format!("{pct}%");
478 let iw = indicator.len() as u16;
479 if area.width > iw {
480 let ix = area.x + area.width - iw;
481 let iy = area.y + area.height.saturating_sub(1);
482 buf.set_line(
483 ix,
484 iy,
485 &Line::from(Span::styled(indicator, theme.muted_style())),
486 iw,
487 );
488 }
489 }
490}
491
492pub fn build_detail_render_data(
495 tc: Option<&DisplayToolCall>,
496 ui_config: &UiConfig,
497 highlighter: &Highlighter,
498 theme: &Theme,
499 content_w: usize,
500) -> SidebarDetailRenderData {
501 let lines = styled_detail_lines(tc, ui_config, highlighter, theme, content_w);
502 let plain_lines = lines.iter().map(line_to_plain_text).collect();
503 SidebarDetailRenderData { lines, plain_lines }
504}
505
506pub fn build_detail_text_surface_from_plain_lines(
507 lines: &[String],
508 area: Rect,
509 scroll: usize,
510) -> TextSurface {
511 if area.height == 0 || area.width == 0 {
512 return TextSurface::new(
513 crate::selection::SelectablePane::SidebarDetail,
514 area,
515 Vec::new(),
516 0,
517 );
518 }
519
520 let rect = area;
521 let lines = lines.to_vec();
522 let start = scroll.min(lines.len().saturating_sub(rect.height as usize));
523
524 TextSurface::new(
525 crate::selection::SelectablePane::SidebarDetail,
526 rect,
527 lines,
528 start,
529 )
530}
531
532pub fn build_detail_text_surface(
533 tc: Option<&DisplayToolCall>,
534 area: Rect,
535 scroll: usize,
536 ui_config: &UiConfig,
537 highlighter: &Highlighter,
538 theme: &Theme,
539) -> TextSurface {
540 if area.height == 0 || area.width == 0 {
541 return TextSurface::new(
542 crate::selection::SelectablePane::SidebarDetail,
543 area,
544 Vec::new(),
545 0,
546 );
547 }
548
549 let render = build_detail_render_data(tc, ui_config, highlighter, theme, area.width as usize);
550 build_detail_text_surface_from_plain_lines(&render.plain_lines, area, scroll)
551}
552
553pub fn render_detail_from_lines(
554 lines: &[Line<'_>],
555 theme: &Theme,
556 scroll: usize,
557 area: Rect,
558 buf: &mut Buffer,
559) {
560 let total = render_scrolled_lines(lines, area, buf, scroll);
561
562 if total > area.height as usize && area.height > 0 {
563 let visible = area.height as usize;
564 let start = scroll.min(total.saturating_sub(visible));
565 let pct = ((start + visible).min(total) * 100) / total;
566 let indicator = format!(" {pct}% ");
567 let iw = indicator.len() as u16;
568 if area.width > iw {
569 let ix = area.x + area.width - iw;
570 let iy = area.y + area.height.saturating_sub(1);
571 buf.set_line(
572 ix,
573 iy,
574 &Line::from(Span::styled(indicator, theme.muted_style())),
575 iw,
576 );
577 }
578 }
579}
580
581fn render_detail(
582 tc: Option<&DisplayToolCall>,
583 theme: &Theme,
584 highlighter: &Highlighter,
585 scroll: usize,
586 ui_config: &UiConfig,
587 area: Rect,
588 buf: &mut Buffer,
589) {
590 if area.height == 0 || area.width == 0 {
591 return;
592 }
593
594 let Some(tc) = tc else {
595 let lines = vec![Line::from(Span::styled(
596 "Select a tool call",
597 theme.muted_style(),
598 ))];
599 render_detail_from_lines(&lines, theme, scroll, area, buf);
600 return;
601 };
602
603 let lines = styled_detail_lines(Some(tc), ui_config, highlighter, theme, area.width as usize);
604 render_detail_from_lines(&lines, theme, scroll, area, buf);
605}
606
607fn styled_detail_lines(
608 tc: Option<&DisplayToolCall>,
609 ui_config: &UiConfig,
610 highlighter: &Highlighter,
611 theme: &Theme,
612 content_w: usize,
613) -> Vec<Line<'static>> {
614 let Some(tc) = tc else {
615 return vec![Line::from(Span::styled(
616 "Select a tool call",
617 theme.muted_style(),
618 ))];
619 };
620
621 let header = tc.header_line_animated_focused(theme, 0, true, ui_config.animations);
622 let full_config = UiConfig {
623 tool_output: ToolOutputDisplay::Full,
624 word_wrap: ui_config.word_wrap,
625 ..*ui_config
626 };
627 let mut lines = vec![header];
628 let input_lines = tool_input_detail_lines(tc, theme, content_w.saturating_sub(2));
629 lines.extend(input_lines);
630 lines.extend(styled_output_lines(
631 tc,
632 &full_config,
633 highlighter,
634 theme,
635 content_w.saturating_sub(2),
636 ));
637 lines
638}
639
640fn tool_input_detail_lines(
641 tc: &DisplayToolCall,
642 theme: &Theme,
643 width: usize,
644) -> Vec<Line<'static>> {
645 let rows = tool_input_summary_rows(tc);
646 if rows.is_empty() {
647 return Vec::new();
648 }
649
650 let mut lines = vec![Line::from(Span::styled("input", theme.muted_style()))];
651 lines.extend(wrap_plain_lines(
652 rows,
653 width,
654 &UiConfig {
655 tool_output: ToolOutputDisplay::Full,
656 word_wrap: true,
657 ..Default::default()
658 },
659 theme,
660 false,
661 ));
662 lines
663}
664
665fn tool_input_summary_rows(tc: &DisplayToolCall) -> Vec<String> {
666 let Some(args) = tc.details.as_object() else {
667 return value_to_summary_rows(&tc.details);
668 };
669
670 match tc.name.as_str() {
671 "shell" | "bash" => summarize_named_fields(args, &["command", "workdir", "timeout"]),
672 "read" => summarize_named_fields(args, &["path", "offset", "limit"]),
673 "edit" => summarize_edit_fields(args),
674 "write" => summarize_write_fields(args),
675 "scan" => summarize_named_fields(args, &["action", "directory", "files", "task"]),
676 "mana" => summarize_named_fields(
677 args,
678 &[
679 "action", "id", "title", "status", "priority", "parent", "deps", "verify", "notes",
680 "reason", "run_id",
681 ],
682 ),
683 "ask" => {
684 summarize_named_fields(args, &["question", "options", "allowOther", "multiSelect"])
685 }
686 "web" => {
687 summarize_named_fields(args, &["action", "query", "url", "provider", "maxResults"])
688 }
689 "spawn" => summarize_named_fields(args, &["mode", "unit_id", "prompt", "timeout_secs"]),
690 _ => summarize_object_fields(args),
691 }
692}
693
694fn summarize_named_fields(args: &serde_json::Map<String, Value>, keys: &[&str]) -> Vec<String> {
695 let mut rows = Vec::new();
696 for key in keys {
697 if let Some(value) = args.get(*key) {
698 push_summary_row(&mut rows, key, value);
699 }
700 }
701 rows
702}
703
704fn summarize_edit_fields(args: &serde_json::Map<String, Value>) -> Vec<String> {
705 let mut rows = summarize_named_fields(args, &["path"]);
706 if let Some(edits) = args.get("edits").and_then(Value::as_array) {
707 rows.push(format!("edits: {}", edits.len()));
708 } else {
709 rows.extend(summarize_named_fields(
710 args,
711 &["oldText", "newText", "replaceAll"],
712 ));
713 }
714 rows
715}
716
717fn summarize_write_fields(args: &serde_json::Map<String, Value>) -> Vec<String> {
718 let mut rows = summarize_named_fields(args, &["path"]);
719 if let Some(content) = args.get("content").and_then(Value::as_str) {
720 rows.push(format!(
721 "content: {} chars, {} lines",
722 content.chars().count(),
723 content.lines().count()
724 ));
725 }
726 rows
727}
728
729fn summarize_object_fields(args: &serde_json::Map<String, Value>) -> Vec<String> {
730 let mut rows = Vec::new();
731 for (key, value) in args {
732 push_summary_row(&mut rows, key, value);
733 }
734 rows
735}
736
737fn value_to_summary_rows(value: &Value) -> Vec<String> {
738 if value.is_null() {
739 Vec::new()
740 } else {
741 vec![format!("value: {}", summarize_value(value))]
742 }
743}
744
745fn push_summary_row(rows: &mut Vec<String>, key: &str, value: &Value) {
746 if let Some(summary) = summarize_field_value(value) {
747 rows.push(format!("{key}: {summary}"));
748 }
749}
750
751fn summarize_field_value(value: &Value) -> Option<String> {
752 match value {
753 Value::Null => None,
754 Value::String(text) => Some(summarize_text(text)),
755 Value::Array(items) => Some(summarize_array(items)),
756 Value::Object(obj) => Some(format!("{{{} fields}}", obj.len())),
757 Value::Bool(_) | Value::Number(_) => Some(summarize_value(value)),
758 }
759}
760
761fn summarize_value(value: &Value) -> String {
762 match value {
763 Value::String(text) => summarize_text(text),
764 Value::Array(items) => summarize_array(items),
765 Value::Object(obj) => format!("{{{} fields}}", obj.len()),
766 Value::Null => "null".to_string(),
767 Value::Bool(value) => value.to_string(),
768 Value::Number(value) => value.to_string(),
769 }
770}
771
772fn summarize_array(items: &[Value]) -> String {
773 const MAX_ITEMS: usize = 6;
774 let mut parts = items
775 .iter()
776 .take(MAX_ITEMS)
777 .map(summarize_value)
778 .collect::<Vec<_>>();
779 if items.len() > MAX_ITEMS {
780 parts.push(format!("… {} more", items.len() - MAX_ITEMS));
781 }
782 format!("[{}]", parts.join(", "))
783}
784
785fn summarize_text(text: &str) -> String {
786 const MAX_TEXT_CHARS: usize = 240;
787 const MAX_TEXT_LINES: usize = 4;
788
789 let mut lines = text.lines().take(MAX_TEXT_LINES).collect::<Vec<_>>();
790 let omitted_lines = text.lines().count().saturating_sub(lines.len());
791 if lines.is_empty() && !text.is_empty() {
792 lines.push(text);
793 }
794
795 let mut summary = lines.join("\\n");
796 summary = truncated_scalar_preview(&summary, MAX_TEXT_CHARS);
797 if omitted_lines > 0 {
798 summary.push_str(&format!(" … {omitted_lines} more lines"));
799 }
800 summary
801}
802
803fn truncated_scalar_preview(value: &str, max_chars: usize) -> String {
804 if value.chars().count() <= max_chars {
805 return value.to_string();
806 }
807
808 let mut out = value.chars().take(max_chars).collect::<String>();
809 out.push('…');
810 out
811}
812
813fn styled_output_lines(
814 tc: &DisplayToolCall,
815 config: &UiConfig,
816 highlighter: &Highlighter,
817 theme: &Theme,
818 width: usize,
819) -> Vec<Line<'static>> {
820 if matches!(config.tool_output, ToolOutputDisplay::Collapsed) {
821 return Vec::new();
822 }
823
824 if tc.name == "mana" {
825 let raw_lines = format_mana_output(tc);
826 let limited = apply_tool_output_limit(raw_lines, config);
827 return wrap_plain_lines(limited, width, config, theme, tc.is_error);
828 }
829
830 if tc.output.is_none() && !tc.streaming_output.is_empty() {
831 let live_lines = tc
832 .streaming_output
833 .lines()
834 .map(String::from)
835 .collect::<Vec<_>>();
836 let limited = apply_tool_output_limit(live_lines, config);
837 return wrap_plain_lines(limited, width, config, theme, tc.is_error);
838 }
839
840 if tc.output.is_none() && !tc.streaming_lines.is_empty() {
841 let limited = apply_tool_output_limit(tc.streaming_lines.clone(), config);
842 return wrap_plain_lines(limited, width, config, theme, tc.is_error);
843 }
844
845 if tc.output.is_none() {
846 return wrap_plain_lines(
847 vec!["Running…".to_string()],
848 width,
849 config,
850 theme,
851 tc.is_error,
852 );
853 }
854
855 let styled = styled_tool_output_lines(tc, highlighter, theme, tc.name == "read");
856 let styled = apply_styled_tool_output_limit(styled, config, theme);
857 if config.word_wrap && width > 0 {
858 wrap_styled_lines(&styled, width.saturating_sub(2))
859 } else {
860 styled
861 }
862}
863
864fn apply_tool_output_limit(raw_lines: Vec<String>, config: &UiConfig) -> Vec<String> {
865 match config.tool_output {
866 ToolOutputDisplay::Compact => {
867 let max = config.tool_output_lines;
868 if raw_lines.len() > max {
869 let mut out: Vec<String> = raw_lines.into_iter().take(max).collect();
870 out.push("…".to_string());
871 out
872 } else {
873 raw_lines
874 }
875 }
876 _ => raw_lines,
877 }
878}
879
880fn apply_styled_tool_output_limit(
881 lines: Vec<Line<'static>>,
882 config: &UiConfig,
883 theme: &Theme,
884) -> Vec<Line<'static>> {
885 match config.tool_output {
886 ToolOutputDisplay::Compact => {
887 let max = config.tool_output_lines;
888 if lines.len() > max {
889 let mut out: Vec<Line<'static>> = lines.into_iter().take(max).collect();
890 out.push(Line::from(Span::styled("…", theme.muted_style())));
891 out
892 } else {
893 lines
894 }
895 }
896 _ => lines,
897 }
898}
899
900fn wrap_plain_lines(
901 lines: Vec<String>,
902 width: usize,
903 config: &UiConfig,
904 theme: &Theme,
905 is_error: bool,
906) -> Vec<Line<'static>> {
907 let style = if is_error {
908 theme.error_style()
909 } else {
910 theme.muted_style()
911 };
912
913 let lines: Vec<Line<'static>> = lines
914 .into_iter()
915 .map(|line| Line::from(Span::styled(line, style)))
916 .collect();
917
918 if config.word_wrap && width > 0 {
919 wrap_styled_lines(&lines, width.saturating_sub(2))
920 } else {
921 lines
922 }
923}
924
925fn indent_line(line: Line<'static>) -> Line<'static> {
926 let mut spans = vec![Span::raw(" ".to_string())];
927 spans.extend(line.spans);
928 Line::from(spans)
929}
930
931fn line_to_plain_text(line: &Line<'_>) -> String {
932 line.spans
933 .iter()
934 .map(|span| span.content.as_ref())
935 .collect()
936}
937fn format_mana_output(tc: &DisplayToolCall) -> Vec<String> {
938 let mut lines = Vec::new();
939
940 let action = tc
941 .details
942 .get("action")
943 .and_then(Value::as_str)
944 .unwrap_or("");
945 if !action.is_empty() {
946 lines.push(format!("action: {action}"));
947
948 match action {
949 "create" => {
950 push_mana_detail_line(&mut lines, "title", tc.details.get("title"));
951 push_mana_detail_line(&mut lines, "description", tc.details.get("description"));
952 push_mana_detail_line(&mut lines, "verify", tc.details.get("verify"));
953 push_mana_detail_line(&mut lines, "priority", tc.details.get("priority"));
954 push_mana_detail_line(&mut lines, "parent", tc.details.get("parent"));
955 push_mana_detail_line(&mut lines, "deps", tc.details.get("deps"));
956 push_mana_detail_line(&mut lines, "labels", tc.details.get("labels"));
957 }
958 "update" => {
959 push_mana_detail_line(&mut lines, "id", tc.details.get("id"));
960 push_mana_detail_line(&mut lines, "status", tc.details.get("status"));
961 push_mana_detail_line(&mut lines, "title", tc.details.get("title"));
962 push_mana_detail_line(&mut lines, "description", tc.details.get("description"));
963 push_mana_detail_line(&mut lines, "priority", tc.details.get("priority"));
964 push_mana_detail_line(&mut lines, "notes", tc.details.get("notes"));
965 }
966 "run" => {
967 push_mana_detail_line(&mut lines, "id", tc.details.get("id"));
968 push_mana_detail_line(&mut lines, "scope", tc.details.get("scope"));
969 push_mana_detail_line(&mut lines, "target", tc.details.get("target"));
970 push_mana_detail_line(&mut lines, "jobs", tc.details.get("jobs"));
971 push_mana_detail_line(&mut lines, "background", tc.details.get("background"));
972 push_mana_detail_line(&mut lines, "dry_run", tc.details.get("dry_run"));
973 push_mana_detail_line(&mut lines, "review", tc.details.get("review"));
974 push_mana_detail_line(&mut lines, "timeout", tc.details.get("timeout"));
975 push_mana_detail_line(&mut lines, "idle_timeout", tc.details.get("idle_timeout"));
976 if let Some(runtime) = tc.details.get("runtime") {
977 push_mana_detail_line(&mut lines, "runtime", Some(runtime));
978 }
979 }
980 "close" | "reopen" | "fail" => {
981 push_mana_detail_line(&mut lines, "id", tc.details.get("id"));
982 push_mana_detail_line(&mut lines, "reason", tc.details.get("reason"));
983 if let Some(unit) = tc.details.get("unit") {
984 push_mana_detail_line(&mut lines, "unit", Some(unit));
985 }
986 }
987 "notes_append" | "decision_add" | "decision_resolve" => {
988 push_mana_detail_line(&mut lines, "id", tc.details.get("id"));
989 push_mana_detail_line(&mut lines, "notes", tc.details.get("notes"));
990 push_mana_detail_line(&mut lines, "description", tc.details.get("description"));
991 push_mana_detail_line(
992 &mut lines,
993 "resolve_decisions",
994 tc.details.get("resolve_decisions"),
995 );
996 if let Some(unit) = tc.details.get("unit") {
997 push_mana_detail_line(&mut lines, "unit", Some(unit));
998 }
999 }
1000 "dep_add" | "dep_remove" => {
1001 push_mana_detail_line(&mut lines, "from_id", tc.details.get("from_id"));
1002 push_mana_detail_line(&mut lines, "dep_id", tc.details.get("dep_id"));
1003 }
1004 "delete" => {
1005 push_mana_detail_line(&mut lines, "id", tc.details.get("id"));
1006 push_mana_detail_line(&mut lines, "title", tc.details.get("title"));
1007 }
1008 "fact_create" => {
1009 push_mana_detail_line(&mut lines, "unit_id", tc.details.get("unit_id"));
1010 if let Some(unit) = tc.details.get("unit") {
1011 push_mana_detail_line(&mut lines, "unit", Some(unit));
1012 }
1013 }
1014 _ => {
1015 for key in ["id", "run_id", "reason", "by", "status", "count"] {
1016 push_mana_detail_line(&mut lines, key, tc.details.get(key));
1017 }
1018 }
1019 }
1020
1021 if !lines.is_empty() {
1022 lines.push(String::new());
1023 }
1024 }
1025
1026 if let Some(view) = tc.details.get("view") {
1027 if let Some(summary) = view.get("summary") {
1028 lines.push("summary".to_string());
1029 lines.push(format!(
1030 " total={} done={} failed={} awaiting-verify={} skipped={}",
1031 summary
1032 .get("total_units")
1033 .and_then(Value::as_u64)
1034 .unwrap_or(0),
1035 summary
1036 .get("total_closed")
1037 .and_then(Value::as_u64)
1038 .unwrap_or(0),
1039 summary
1040 .get("total_failed")
1041 .and_then(Value::as_u64)
1042 .unwrap_or(0),
1043 summary
1044 .get("total_awaiting_verify")
1045 .and_then(Value::as_u64)
1046 .unwrap_or(0),
1047 summary
1048 .get("total_skipped")
1049 .and_then(Value::as_u64)
1050 .unwrap_or(0)
1051 ));
1052 }
1053
1054 if let Some(units) = view.get("units").and_then(Value::as_array) {
1055 if !units.is_empty() {
1056 if !lines.is_empty() {
1057 lines.push(String::new());
1058 }
1059 lines.push("units".to_string());
1060 }
1061 for unit in units {
1062 let status = unit
1063 .get("status")
1064 .and_then(Value::as_str)
1065 .unwrap_or("queued");
1066 let marker = match status {
1067 "running" => "▶",
1068 "done" => "✓",
1069 "failed" => "✗",
1070 "blocked" => "!",
1071 _ => "…",
1072 };
1073 let id = unit.get("id").and_then(Value::as_str).unwrap_or("?");
1074 let title = unit.get("title").and_then(Value::as_str).unwrap_or("");
1075 lines.push(format!(" {marker} {id} {title}"));
1076
1077 let mut meta = Vec::new();
1078 meta.push(format!("status={status}"));
1079 if let Some(round) = unit.get("round").and_then(Value::as_u64) {
1080 meta.push(format!("wave={round}"));
1081 }
1082 if let Some(agent) = unit.get("agent").and_then(Value::as_str) {
1083 meta.push(format!("agent={agent}"));
1084 }
1085 if let Some(duration) = unit.get("duration_secs").and_then(Value::as_u64) {
1086 meta.push(format!("duration={}s", duration));
1087 }
1088 if let Some(error) = unit.get("error").and_then(Value::as_str) {
1089 meta.push(format!("error={error}"));
1090 }
1091 if !meta.is_empty() {
1092 lines.push(format!(" {}", meta.join(" ")));
1093 }
1094 }
1095 }
1096 } else if !tc.streaming_output.is_empty() {
1097 lines.extend(tc.streaming_output.lines().map(String::from));
1098 } else if !tc.streaming_lines.is_empty() {
1099 lines.extend(tc.streaming_lines.clone());
1100 } else if let Some(ref output) = tc.output {
1101 lines.extend(output.lines().map(String::from));
1102 }
1103
1104 if lines.is_empty() {
1105 vec!["Running…".to_string()]
1106 } else {
1107 lines
1108 }
1109}
1110
1111fn push_mana_detail_line(lines: &mut Vec<String>, key: &str, value: Option<&Value>) {
1112 let Some(value) = value else {
1113 return;
1114 };
1115 let rendered = match value {
1116 Value::Null => return,
1117 Value::String(s) => s.clone(),
1118 Value::Bool(b) => b.to_string(),
1119 Value::Number(n) => n.to_string(),
1120 Value::Array(items) => items
1121 .iter()
1122 .filter_map(|item| match item {
1123 Value::String(s) => Some(s.clone()),
1124 Value::Bool(b) => Some(b.to_string()),
1125 Value::Number(n) => Some(n.to_string()),
1126 _ => None,
1127 })
1128 .collect::<Vec<_>>()
1129 .join(", "),
1130 Value::Object(map) => {
1131 if let (Some(kind), Some(ids)) = (
1132 map.get("kind").and_then(Value::as_str),
1133 map.get("ids").and_then(Value::as_array),
1134 ) {
1135 let ids = ids
1136 .iter()
1137 .filter_map(Value::as_str)
1138 .collect::<Vec<_>>()
1139 .join(", ");
1140 format!("{kind}: {ids}")
1141 } else if let (Some(kind), Some(id)) = (
1142 map.get("kind").and_then(Value::as_str),
1143 map.get("id").and_then(Value::as_str),
1144 ) {
1145 format!("{kind}: {id}")
1146 } else if let (Some(agent), Some(model)) = (
1147 map.get("direct_agent").and_then(Value::as_str),
1148 map.get("model").and_then(Value::as_str),
1149 ) {
1150 format!("{agent} · {model}")
1151 } else if let (Some(id), Some(title)) = (
1152 map.get("id").and_then(Value::as_str),
1153 map.get("title").and_then(Value::as_str),
1154 ) {
1155 let status = map
1156 .get("status")
1157 .and_then(Value::as_str)
1158 .map(|s| format!(" · {s}"))
1159 .unwrap_or_default();
1160 format!("{id} · {title}{status}")
1161 } else {
1162 serde_json::to_string(value).unwrap_or_default()
1163 }
1164 }
1165 };
1166 if !rendered.is_empty() {
1167 lines.push(format!("{key}: {rendered}"));
1168 }
1169}
1170
1171#[cfg(test)]
1172fn wrap_into(line: &str, width: usize, out: &mut Vec<String>) {
1173 if width == 0 {
1174 out.push(String::new());
1175 return;
1176 }
1177
1178 let chars: Vec<char> = line.chars().collect();
1179 if chars.len() <= width {
1180 out.push(line.to_string());
1181 return;
1182 }
1183
1184 let mut start = 0;
1185 while start < chars.len() {
1186 let remaining = chars.len() - start;
1187 if remaining <= width {
1188 out.push(chars[start..].iter().collect());
1189 break;
1190 }
1191
1192 let end = start + width;
1193 if end >= chars.len() || chars[end] == ' ' {
1194 let segment: String = chars[start..end].iter().collect();
1195 out.push(segment);
1196 start = if end < chars.len() { end + 1 } else { end };
1197 continue;
1198 }
1199
1200 let mut break_at = None;
1201 for i in (start + 1..end).rev() {
1202 if chars[i] == ' ' {
1203 break_at = Some(i);
1204 break;
1205 }
1206 }
1207
1208 if let Some(bp) = break_at {
1209 let segment: String = chars[start..bp].iter().collect();
1210 out.push(segment);
1211 start = bp + 1;
1212 } else {
1213 let segment: String = chars[start..end].iter().collect();
1214 out.push(segment);
1215 start = end;
1216 }
1217 }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use ratatui::buffer::Buffer;
1224 use ratatui::layout::Rect;
1225
1226 #[test]
1229 fn sidebar_default_state() {
1230 let sidebar = Sidebar::default();
1231 assert!(!sidebar.open);
1232 assert_eq!(sidebar.list_scroll, 0);
1233 assert_eq!(sidebar.detail_scroll, 0);
1234 assert!(!sidebar.first_tool_seen);
1235 }
1236
1237 #[test]
1238 fn sidebar_scroll_list() {
1239 let mut sidebar = Sidebar::default();
1240 sidebar.scroll_list_down(5);
1241 assert_eq!(sidebar.list_scroll, 5);
1242 sidebar.scroll_list_up(3);
1243 assert_eq!(sidebar.list_scroll, 2);
1244 sidebar.scroll_list_up(10);
1245 assert_eq!(sidebar.list_scroll, 0);
1246 }
1247
1248 #[test]
1249 fn sidebar_scroll_detail() {
1250 let mut sidebar = Sidebar::default();
1251 sidebar.scroll_detail_down(5);
1252 assert_eq!(sidebar.detail_scroll, 5);
1253 sidebar.scroll_detail_up(3);
1254 assert_eq!(sidebar.detail_scroll, 2);
1255 sidebar.scroll_detail_up(10);
1256 assert_eq!(sidebar.detail_scroll, 0);
1257 }
1258
1259 #[test]
1260 fn sidebar_ensure_selected_visible_scrolls_down() {
1261 let mut sidebar = Sidebar {
1262 list_height: 5,
1263 ..Sidebar::default()
1264 };
1265 sidebar.ensure_selected_visible(7);
1266 assert!(sidebar.list_scroll + 5 > 7);
1267 }
1268
1269 #[test]
1270 fn sidebar_ensure_selected_visible_scrolls_up() {
1271 let mut sidebar = Sidebar {
1272 list_height: 5,
1273 list_scroll: 10,
1274 ..Sidebar::default()
1275 };
1276 sidebar.ensure_selected_visible(3);
1277 assert_eq!(sidebar.list_scroll, 3);
1278 }
1279
1280 #[test]
1283 fn compute_split_too_small() {
1284 let area = Rect::new(0, 0, 40, 4);
1285 let (list, sep, detail) = compute_split(area, 5);
1286 assert_eq!(list.height, 4);
1287 assert!(sep.is_none());
1288 assert_eq!(detail.height, 0);
1289 }
1290
1291 #[test]
1292 fn compute_split_few_tools() {
1293 let area = Rect::new(0, 0, 40, 20);
1294 let (list, sep, detail) = compute_split(area, 3);
1295 assert!(sep.is_some());
1296 assert!(list.height >= 2);
1297 assert!(detail.height >= 3);
1298 assert_eq!(list.height as usize + 1 + detail.height as usize, 20);
1299 }
1300
1301 #[test]
1302 fn sidebar_sub_areas_stream_covers_full() {
1303 let sidebar = Rect::new(50, 0, 30, 20);
1304 let (top, bottom) = sidebar_sub_areas(sidebar, 5, SidebarStyle::Stream);
1305 assert_eq!(top.height, 20);
1306 assert_eq!(bottom.height, 0);
1307 }
1308
1309 #[test]
1310 fn sidebar_sub_areas_split_has_two_regions() {
1311 let sidebar = Rect::new(50, 0, 30, 20);
1312 let (top, bottom) = sidebar_sub_areas(sidebar, 5, SidebarStyle::Split);
1313 assert!(top.height > 0);
1314 assert!(bottom.height > 0);
1315 }
1316
1317 #[test]
1318 fn format_mana_output_renders_summary_and_units() {
1319 let tc = DisplayToolCall {
1320 id: "1".into(),
1321 name: "mana".into(),
1322 args_summary: "run".into(),
1323 output: None,
1324 details: serde_json::json!({
1325 "action": "run",
1326 "jobs": 4,
1327 "background": true,
1328 "view": {
1329 "summary": {
1330 "total_units": 3,
1331 "total_closed": 2,
1332 "total_failed": 1,
1333 "total_awaiting_verify": 0,
1334 "total_skipped": 0
1335 },
1336 "units": [
1337 {"id": "1.1", "title": "First", "status": "done", "round": 1, "duration_secs": 8},
1338 {"id": "1.2", "title": "Second", "status": "failed", "round": 1}
1339 ]
1340 }
1341 }),
1342 is_error: false,
1343 expanded: false,
1344 streaming_lines: Vec::new(),
1345 streaming_output: String::new(),
1346 };
1347
1348 let lines = format_mana_output(&tc);
1349 assert_eq!(lines[0], "action: run");
1350 assert!(lines.iter().any(|l| l == "jobs: 4"));
1351 assert!(lines.iter().any(|l| l == "background: true"));
1352 assert!(lines.iter().any(|l| l == "summary"));
1353 assert!(lines
1354 .iter()
1355 .any(|l| l.contains("total=3 done=2 failed=1 awaiting-verify=0 skipped=0")));
1356 assert!(lines.iter().any(|l| l == "units"));
1357 assert!(lines.iter().any(|l| l.contains("✓ 1.1 First")));
1358 assert!(lines
1359 .iter()
1360 .any(|l| l.contains("status=done wave=1 duration=8s")));
1361 assert!(lines.iter().any(|l| l.contains("✗ 1.2 Second")));
1362 assert!(lines.iter().any(|l| l.contains("status=failed wave=1")));
1363 }
1364
1365 #[test]
1366 fn format_mana_output_renders_scope_target_and_runtime() {
1367 let tc = DisplayToolCall {
1368 id: "run-1".into(),
1369 name: "mana".into(),
1370 args_summary: "run".into(),
1371 output: None,
1372 details: serde_json::json!({
1373 "action": "run",
1374 "scope": "targets 1, 2",
1375 "target": {"kind": "explicit", "ids": ["1", "2"]},
1376 "runtime": {"direct_agent": "imp", "model": "sonnet"},
1377 "background": true,
1378 "view": {
1379 "summary": {
1380 "total_units": 2,
1381 "total_closed": 2,
1382 "total_failed": 0,
1383 "total_awaiting_verify": 0,
1384 "total_skipped": 0
1385 },
1386 "units": []
1387 }
1388 }),
1389 is_error: false,
1390 expanded: false,
1391 streaming_lines: Vec::new(),
1392 streaming_output: String::new(),
1393 };
1394
1395 let lines = format_mana_output(&tc);
1396 assert!(lines.iter().any(|l| l == "scope: targets 1, 2"));
1397 assert!(lines.iter().any(|l| l == "target: explicit: 1, 2"));
1398 assert!(lines.iter().any(|l| l == "runtime: imp · sonnet"));
1399 }
1400
1401 #[test]
1402 fn format_mana_output_renders_delta_actions() {
1403 let tc = DisplayToolCall {
1404 id: "delta-1".into(),
1405 name: "mana".into(),
1406 args_summary: "decision_add".into(),
1407 output: Some("mana delta: decision added on 1 · Test unit".into()),
1408 details: serde_json::json!({
1409 "action": "decision_add",
1410 "id": "1",
1411 "description": "Choose retry limit",
1412 "unit": {
1413 "id": "1",
1414 "title": "Test unit",
1415 "status": "open",
1416 "decisions": ["Choose retry limit"]
1417 }
1418 }),
1419 is_error: false,
1420 expanded: false,
1421 streaming_lines: Vec::new(),
1422 streaming_output: String::new(),
1423 };
1424
1425 let lines = format_mana_output(&tc);
1426 assert!(lines.iter().any(|l| l == "action: decision_add"));
1427 assert!(lines.iter().any(|l| l == "id: 1"));
1428 assert!(lines.iter().any(|l| l == "description: Choose retry limit"));
1429 assert!(lines.iter().any(|l| l == "unit: 1 · Test unit · open"));
1430 assert!(lines
1431 .iter()
1432 .any(|l| l.contains("mana delta: decision added on 1 · Test unit")));
1433 }
1434
1435 #[test]
1436 fn wrap_short_line_unchanged() {
1437 let mut out = Vec::new();
1438 wrap_into("hello", 10, &mut out);
1439 assert_eq!(out, vec!["hello"]);
1440 }
1441
1442 #[test]
1443 fn wrap_at_space() {
1444 let mut out = Vec::new();
1445 wrap_into("hello world foo", 11, &mut out);
1446 assert_eq!(out, vec!["hello world", "foo"]);
1447 }
1448
1449 #[test]
1450 fn wrap_long_word_force_break() {
1451 let mut out = Vec::new();
1452 wrap_into("abcdefghij", 4, &mut out);
1453 assert_eq!(out, vec!["abcd", "efgh", "ij"]);
1454 }
1455
1456 #[test]
1457 fn wrap_empty() {
1458 let mut out = Vec::new();
1459 wrap_into("", 10, &mut out);
1460 assert_eq!(out, vec![""]);
1461 }
1462
1463 #[test]
1464 fn inspector_sidebar_uses_full_area_for_detail() {
1465 let area = Rect::new(10, 2, 40, 12);
1466 let (list, detail) = sidebar_sub_areas(area, 3, SidebarStyle::Inspector);
1467
1468 assert_eq!(list, detail);
1469 assert_eq!(detail.x, area.x);
1470 assert_eq!(detail.width, area.width);
1471 assert_eq!(detail.y, area.y);
1472 assert_eq!(detail.height, area.height);
1473 }
1474
1475 fn make_tc(name: &str, args: &str, output: Option<&str>, is_error: bool) -> DisplayToolCall {
1478 DisplayToolCall {
1479 id: format!("tc-{name}"),
1480 name: name.into(),
1481 args_summary: args.into(),
1482 output: output.map(String::from),
1483 details: serde_json::Value::Null,
1484 is_error,
1485 expanded: false,
1486 streaming_lines: Vec::new(),
1487 streaming_output: String::new(),
1488 }
1489 }
1490
1491 #[test]
1492 fn inspector_detail_includes_selected_tool_header_and_full_output() {
1493 let tc = make_tc("bash", "$ printf", Some("line1\nline2"), false);
1494 let config = UiConfig {
1495 sidebar_style: SidebarStyle::Inspector,
1496 tool_output: ToolOutputDisplay::Compact,
1497 tool_output_lines: 1,
1498 word_wrap: false,
1499 ..Default::default()
1500 };
1501
1502 let render = build_detail_render_data(
1503 Some(&tc),
1504 &config,
1505 &crate::highlight::Highlighter::new(),
1506 &Theme::default(),
1507 80,
1508 );
1509
1510 assert!(render.plain_lines.iter().any(|line| line.contains("bash")));
1511 assert!(render
1512 .plain_lines
1513 .iter()
1514 .any(|line| line.contains("$ printf")));
1515 assert!(render.plain_lines.iter().any(|line| line == "line1"));
1516 assert!(render.plain_lines.iter().any(|line| line == "line2"));
1517 assert!(!render.plain_lines.iter().any(|line| line == "…"));
1518 }
1519
1520 #[test]
1521 fn inspector_detail_includes_tool_input_arguments() {
1522 let mut tc = make_tc("shell", "run", Some("done"), false);
1523 tc.details = serde_json::json!({
1524 "command": "cargo test -p imp-tui inspector -- --nocapture",
1525 "timeout": 120000,
1526 });
1527 let config = UiConfig {
1528 sidebar_style: SidebarStyle::Inspector,
1529 tool_output: ToolOutputDisplay::Compact,
1530 tool_output_lines: 1,
1531 word_wrap: false,
1532 ..Default::default()
1533 };
1534
1535 let render = build_detail_render_data(
1536 Some(&tc),
1537 &config,
1538 &crate::highlight::Highlighter::new(),
1539 &Theme::default(),
1540 120,
1541 );
1542
1543 assert!(render.plain_lines.iter().any(|line| line == "input"));
1544 assert!(render
1545 .plain_lines
1546 .iter()
1547 .any(|line| line.contains("cargo test -p imp-tui inspector")));
1548 assert!(render
1549 .plain_lines
1550 .iter()
1551 .any(|line| line.contains("timeout")));
1552 assert!(render.plain_lines.iter().any(|line| line == "done"));
1553 }
1554
1555 #[test]
1556 fn inspector_detail_summarizes_large_tool_input_arguments() {
1557 let mut tc = make_tc("edit", "run", Some("done"), false);
1558 tc.details = serde_json::json!({
1559 "edits": (0..120).map(|idx| serde_json::json!({
1560 "oldText": format!("old-{idx}"),
1561 "newText": "x".repeat(10_000),
1562 })).collect::<Vec<_>>(),
1563 });
1564
1565 let render = build_detail_render_data(
1566 Some(&tc),
1567 &UiConfig {
1568 sidebar_style: SidebarStyle::Inspector,
1569 word_wrap: true,
1570 ..Default::default()
1571 },
1572 &crate::highlight::Highlighter::new(),
1573 &Theme::default(),
1574 40,
1575 );
1576
1577 assert!(render.plain_lines.iter().any(|line| line == "input"));
1578 assert!(render
1579 .plain_lines
1580 .iter()
1581 .any(|line| line.contains("edits: 120")));
1582 assert!(!render
1583 .plain_lines
1584 .iter()
1585 .any(|line| line.contains("old-119")));
1586 assert!(render.plain_lines.iter().all(|line| line.len() < 1_000));
1587 }
1588
1589 #[test]
1590 fn styled_output_lines_read_include_numbered_source() {
1591 let mut tc = make_tc("read", "f.rs", Some("fn main() {}"), false);
1592 tc.details = serde_json::json!({"path": "src/main.rs", "lines": 1});
1593 let config = UiConfig {
1594 tool_output: ToolOutputDisplay::Full,
1595 word_wrap: false,
1596 ..Default::default()
1597 };
1598 let lines = styled_output_lines(
1599 &tc,
1600 &config,
1601 &crate::highlight::Highlighter::new(),
1602 &Theme::default(),
1603 80,
1604 );
1605 let plain: Vec<String> = lines
1606 .into_iter()
1607 .map(|line| line.spans.into_iter().map(|span| span.content).collect())
1608 .collect();
1609 assert!(plain[0].starts_with(" 1 │ "));
1610 assert!(plain[0].contains("fn main()"));
1611 }
1612
1613 #[test]
1614 fn styled_output_lines_use_live_streaming_output_in_sidebar() {
1615 let mut tc = make_tc("bash", "$ echo hi", None, false);
1616 tc.streaming_output = "line 1\nline 2".into();
1617 let config = UiConfig {
1618 tool_output: ToolOutputDisplay::Full,
1619 word_wrap: false,
1620 ..Default::default()
1621 };
1622
1623 let lines = styled_output_lines(
1624 &tc,
1625 &config,
1626 &crate::highlight::Highlighter::new(),
1627 &Theme::default(),
1628 80,
1629 );
1630 let plain: Vec<String> = lines
1631 .into_iter()
1632 .map(|line| line.spans.into_iter().map(|span| span.content).collect())
1633 .collect();
1634 assert_eq!(plain, vec!["line 1".to_string(), "line 2".to_string()]);
1635 }
1636
1637 #[test]
1638 fn styled_output_lines_write_show_file_content() {
1639 let mut tc = make_tc("write", "f.rs", Some("summary only"), false);
1640 tc.details = serde_json::json!({
1641 "path": "src/lib.rs",
1642 "summary": "src/lib.rs: 12 bytes created",
1643 "display_content": "pub fn hi() {}",
1644 "display_note": ""
1645 });
1646 let config = UiConfig {
1647 tool_output: ToolOutputDisplay::Full,
1648 word_wrap: false,
1649 ..Default::default()
1650 };
1651 let lines = styled_output_lines(
1652 &tc,
1653 &config,
1654 &crate::highlight::Highlighter::new(),
1655 &Theme::default(),
1656 80,
1657 );
1658 let plain: Vec<String> = lines
1659 .into_iter()
1660 .map(|line| line.spans.into_iter().map(|span| span.content).collect())
1661 .collect();
1662 assert!(plain.iter().any(|line| line.contains("pub fn hi")));
1663 }
1664
1665 #[test]
1666 fn styled_output_lines_wrap_long_plain_lines() {
1667 let tc = make_tc(
1668 "bash",
1669 "$ echo",
1670 Some("this is a very long line that should wrap inside the sidebar viewer"),
1671 false,
1672 );
1673 let config = UiConfig {
1674 tool_output: ToolOutputDisplay::Full,
1675 word_wrap: true,
1676 ..Default::default()
1677 };
1678
1679 let lines = styled_output_lines(
1680 &tc,
1681 &config,
1682 &crate::highlight::Highlighter::new(),
1683 &Theme::default(),
1684 20,
1685 );
1686
1687 assert!(lines.len() > 1);
1688 }
1689
1690 #[test]
1693 fn build_detail_text_surface_uses_full_area_without_header_offset() {
1694 let tc = make_tc("bash", "$ ls", Some("line1\nline2\nline3"), false);
1695 let config = UiConfig {
1696 sidebar_style: SidebarStyle::Split,
1697 word_wrap: false,
1698 ..Default::default()
1699 };
1700 let area = Rect::new(10, 5, 30, 6);
1701
1702 let theme = Theme::default();
1703 let highlighter = crate::highlight::Highlighter::new();
1704 let surface = build_detail_text_surface(Some(&tc), area, 0, &config, &highlighter, &theme);
1705
1706 assert_eq!(surface.rect, area);
1707 }
1708
1709 #[test]
1710 fn sidebar_view_empty_no_panic() {
1711 let theme = Theme::default();
1712 let config = UiConfig::default();
1713 let highlighter = crate::highlight::Highlighter::new();
1714 let view = SidebarView::new(vec![], None, &theme, &highlighter, 0, 0, 0, &config);
1715 let area = Rect::new(0, 0, 40, 10);
1716 let mut buf = Buffer::empty(area);
1717 view.render(area, &mut buf);
1718 }
1719
1720 #[test]
1721 fn sidebar_view_stream_mode_no_panic() {
1722 let theme = Theme::default();
1723 let config = UiConfig {
1724 sidebar_style: SidebarStyle::Stream,
1725 ..Default::default()
1726 };
1727 let tc1 = make_tc("read", "file.rs", Some("fn main() {}"), false);
1728 let tc2 = make_tc("bash", "$ ls", Some("file1\nfile2"), false);
1729 let highlighter = crate::highlight::Highlighter::new();
1730 let view = SidebarView::new(
1731 vec![&tc1, &tc2],
1732 Some(0),
1733 &theme,
1734 &highlighter,
1735 0,
1736 0,
1737 0,
1738 &config,
1739 );
1740 let area = Rect::new(0, 0, 50, 20);
1741 let mut buf = Buffer::empty(area);
1742 view.render(area, &mut buf);
1743 }
1744
1745 #[test]
1746 fn sidebar_view_split_mode_no_panic() {
1747 let theme = Theme::default();
1748 let config = UiConfig {
1749 sidebar_style: SidebarStyle::Split,
1750 ..Default::default()
1751 };
1752 let tc1 = make_tc("read", "file.rs", Some("fn main() {}"), false);
1753 let tc2 = make_tc("bash", "$ ls", Some("file1\nfile2"), false);
1754 let highlighter = crate::highlight::Highlighter::new();
1755 let view = SidebarView::new(
1756 vec![&tc1, &tc2],
1757 Some(1),
1758 &theme,
1759 &highlighter,
1760 0,
1761 0,
1762 0,
1763 &config,
1764 );
1765 let area = Rect::new(0, 0, 50, 20);
1766 let mut buf = Buffer::empty(area);
1767 view.render(area, &mut buf);
1768 }
1769
1770 #[test]
1771 fn sidebar_view_tiny_no_panic() {
1772 let theme = Theme::default();
1773 let config = UiConfig::default();
1774 let tc = make_tc("read", "f.rs", Some("hello"), false);
1775 let highlighter = crate::highlight::Highlighter::new();
1776 let view = SidebarView::new(vec![&tc], Some(0), &theme, &highlighter, 0, 0, 0, &config);
1777 let area = Rect::new(0, 0, 2, 1);
1778 let mut buf = Buffer::empty(area);
1779 view.render(area, &mut buf);
1780 }
1781}