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