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