Skip to main content

imp_tui/views/
sidebar.rs

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// ── Sidebar state ───────────────────────────────────────────────
22
23/// Sidebar state tracked in App.
24#[derive(Default)]
25pub struct Sidebar {
26    /// Whether the sidebar pane is visible.
27    pub open: bool,
28    /// Scroll offset for the tool list pane (split mode, 0 = top).
29    pub list_scroll: usize,
30    /// Scroll offset for the detail/stream pane (0 = top).
31    pub detail_scroll: usize,
32    /// Whether the first tool has been seen (for auto-open logic).
33    pub first_tool_seen: bool,
34    /// Cached list pane height from last render (for scroll bounds).
35    pub list_height: u16,
36}
37
38impl Sidebar {
39    /// Reset detail scroll (call when selection changes).
40    pub fn reset_detail_scroll(&mut self) {
41        self.detail_scroll = 0;
42    }
43
44    /// Scroll the tool list up (toward earlier entries).
45    pub fn scroll_list_up(&mut self, n: usize) {
46        self.list_scroll = self.list_scroll.saturating_sub(n);
47    }
48
49    /// Scroll the tool list down (toward later entries).
50    pub fn scroll_list_down(&mut self, n: usize) {
51        self.list_scroll += n;
52    }
53
54    /// Scroll the detail/stream pane up (toward earlier content).
55    pub fn scroll_detail_up(&mut self, n: usize) {
56        self.detail_scroll = self.detail_scroll.saturating_sub(n);
57    }
58
59    /// Scroll the detail/stream pane down (toward later content).
60    pub fn scroll_detail_down(&mut self, n: usize) {
61        self.detail_scroll += n;
62    }
63
64    /// Ensure the selected tool call index is visible in the list (split mode).
65    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
75// ── Layout computation ──────────────────────────────────────────
76
77/// Compute sidebar sub-areas for external hit-testing.
78/// Returns `(top_hit_rect, bottom_hit_rect)` in screen coordinates.
79/// In stream mode, top covers the full sidebar (bottom is zero-height).
80/// In split mode, top = list area, bottom = detail area.
81pub 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            // Stream: single scrollable pane — top covers everything
104            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
137/// Split-mode layout: list, separator, detail areas.
138fn 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
177// ── SidebarView widget ──────────────────────────────────────────
178
179/// Widget that renders the sidebar in either stream or split mode.
180pub 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        // Left border separator
238        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
343// ── Stream mode rendering ───────────────────────────────────────
344
345fn 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/// Render the sidebar as a single chronological stream of tool calls
453/// with their results shown inline underneath each header.
454#[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// ── Split mode: tool list ───────────────────────────────────────
487
488#[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
540// ── Split mode: detail pane ─────────────────────────────────────
541
542pub 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    // ── Sidebar state ───────────────────────────────────────────
1318
1319    #[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    // ── Layout ──────────────────────────────────────────────────
1372
1373    #[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    // ── Tool output lines ───────────────────────────────────────
1569
1570    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    // ── Widget rendering ────────────────────────────────────────
1784
1785    #[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}