Skip to main content

thndrs_lib/cli/renderer/
transcript.rs

1//! Semantic transcript row construction for the terminal renderer.
2//!
3//! This module owns turning transcript [`Entry`] values into [`Row`] blocks.
4
5use std::path::Path;
6
7use crate::app::{App, Entry, ToolStatus};
8use crate::internals::StartupSection;
9use crate::renderer::row::Row;
10use crate::renderer::style::{CellStyle, Color, Span};
11use crate::{renderer, utils};
12
13/// Maximum tool output lines rendered before a truncation marker is shown.
14const MAX_TOOL_OUTPUT_LINES: usize = 6;
15
16/// Minimum content width where markdown tables remain legible.
17const MIN_TABLE_RENDER_WIDTH: usize = 24;
18
19/// Gutter prefix for tool output lines.
20pub const GUTTER: &str = "   │ ";
21
22/// Role rail shown on transcript entry rows and partial-entry continuations.
23pub const ENTRY_RAIL: &str = "│ ";
24
25#[derive(Clone, Copy)]
26enum TableAlign {
27    Left,
28    Right,
29    Center,
30}
31
32/// Context needed to render a single transcript entry into rows.
33#[derive(Clone)]
34pub struct TranscriptRowContext<'a> {
35    pub user_label: &'a str,
36    pub cwd: &'a Path,
37    pub width: usize,
38    /// Index of the entry in the transcript. When present, rows are tagged with a
39    /// [`renderer::row::RowGroupId`] so viewport navigation can correlate rows to the
40    /// originating entry.
41    pub entry_index: Option<usize>,
42    /// Whether this entry begins a consecutive group of tool activity.
43    pub tool_group_start: bool,
44}
45
46impl TranscriptRowContext<'_> {
47    /// Build all rows for a single transcript entry.
48    pub fn rows_for_entry(&self, entry: &Entry) -> Vec<Row> {
49        let mut rows = entry_to_rows(entry, self.user_label, self.width, self.cwd, self.tool_group_start);
50        if let Some(index) = self.entry_index {
51            let group_id = renderer::row::RowGroupId { entry_index: index };
52            for row in &mut rows {
53                row.group_id = Some(group_id);
54            }
55        }
56        rows
57    }
58
59    /// Split a single entry into stable and live rows.
60    ///
61    /// Streaming assistant/reasoning blocks and running tools are entirely live
62    /// until they finish. All other entries are fully stable.
63    pub fn rows_for_entry_stable_and_live_rows(&self, entry: &Entry) -> (Vec<Row>, Vec<Row>) {
64        let rows = self.rows_for_entry(entry);
65        match entry {
66            Entry::Agent { streaming: true, .. }
67            | Entry::Reasoning { streaming: true, .. }
68            | Entry::Tool { status: ToolStatus::Running, .. } => (Vec::new(), rows),
69            _ => (rows, Vec::new()),
70        }
71    }
72}
73
74impl<'a> TranscriptRowContext<'a> {
75    /// Build a context without entry grouping.
76    #[cfg(test)]
77    pub fn for_test(user_label: &'a str, cwd: &'a Path, width: usize) -> Self {
78        Self { user_label, cwd, width, entry_index: None, tool_group_start: true }
79    }
80}
81
82#[derive(Clone, Copy)]
83struct StartupBannerTheme {
84    width: usize,
85    brand_style: CellStyle,
86    attention_style: CellStyle,
87    muted_style: CellStyle,
88    hint_style: CellStyle,
89    rail_style: CellStyle,
90    success_style: CellStyle,
91    meta_style: CellStyle,
92}
93
94impl StartupBannerTheme {
95    fn body_width(self) -> usize {
96        super::layout::content_width(self.width).saturating_sub(3)
97    }
98}
99
100#[derive(Clone, Copy)]
101struct LabeledBlock {
102    rail_style: CellStyle,
103    label_style: CellStyle,
104    text_style: CellStyle,
105    bg: Color,
106    width: usize,
107    body_width: usize,
108}
109
110impl LabeledBlock {
111    fn new(
112        rail_style: CellStyle, label_style: CellStyle, text_style: CellStyle, bg: Color, width: usize,
113        body_width: usize,
114    ) -> Self {
115        Self { rail_style, label_style, text_style, bg, width, body_width }
116    }
117
118    /// Build a labeled text block with a single leading spacer row.
119    fn build(self, label: &str, text: &str) -> Vec<Row> {
120        let mut rows = vec![
121            Row::blank(self.width, CellStyle::new().bg(self.bg)),
122            Row::padded(
123                vec![
124                    Span::styled(ENTRY_RAIL, self.rail_style),
125                    Span::styled(label.to_string(), self.label_style),
126                ],
127                self.width,
128                CellStyle::new().bg(self.bg),
129            ),
130        ];
131
132        for line in super::layout::wrap_text(text, self.body_width) {
133            match line.is_empty() {
134                true => rows.push(Row::blank(self.width, CellStyle::new().bg(self.bg))),
135                false => rows.push(Row::padded(
136                    vec![
137                        Span::styled(ENTRY_RAIL, self.rail_style),
138                        Span::styled(line, self.text_style),
139                    ],
140                    self.width,
141                    CellStyle::new().bg(self.bg),
142                )),
143            }
144        }
145        rows
146    }
147}
148
149#[derive(Copy, Clone)]
150struct BannerIndent(usize, usize);
151
152impl BannerIndent {
153    fn first(&self) -> usize {
154        self.0
155    }
156
157    fn continuation(&self) -> usize {
158        self.1
159    }
160}
161
162/// Build a tool block: header row + args summary + output lines + vertical padding.
163struct ToolBlockView<'a> {
164    name: &'a str,
165    args: &'a str,
166    status: ToolStatus,
167    output: &'a [String],
168    width: usize,
169    body_width: usize,
170    bg: Color,
171    cwd: &'a Path,
172    group_start: bool,
173}
174
175impl ToolBlockView<'_> {
176    fn rows(&self) -> Vec<Row> {
177        let p = super::style::palette();
178        let (status_label, status_color, icon) = match self.status {
179            ToolStatus::Running => ("running", p.peach, "·"),
180            ToolStatus::Ok => ("ok", p.green, "✓"),
181            ToolStatus::Failed => ("failed", p.red, "✕"),
182            ToolStatus::Cancelled => ("cancelled", p.peach, "○"),
183        };
184        let header_style = CellStyle::new().fg(p.text).bg(self.bg).bold();
185        let status_style = CellStyle::new().fg(status_color).bg(self.bg);
186        let muted_style = CellStyle::new().fg(p.subtext0).bg(self.bg);
187        let gutter_style = CellStyle::new().fg(p.overlay0).bg(self.bg);
188        let rail_style = CellStyle::new().fg(p.yellow).bg(self.bg);
189        let content_width = self.body_width.saturating_sub(utils::text_width(ENTRY_RAIL));
190        let tool_content_width = content_width.saturating_sub(utils::text_width(GUTTER));
191
192        let base_name = self.name.split('#').next().unwrap_or(self.name);
193        let args_summary = summarize_tool_invocation(base_name, self.args, self.cwd);
194        let lang = super::highlight::tool_output_language(base_name, self.args);
195
196        let mut rows = Vec::new();
197        if self.group_start {
198            rows.push(Row::blank(self.width, CellStyle::new().bg(self.bg)));
199            rows.push(Row::padded(
200                vec![
201                    Span::styled(ENTRY_RAIL, rail_style),
202                    Span::styled("Activity", CellStyle::new().fg(p.yellow).bg(self.bg).bold()),
203                ],
204                self.width,
205                CellStyle::new().bg(self.bg),
206            ));
207        }
208
209        let mut header_spans = vec![
210            Span::styled(ENTRY_RAIL, rail_style),
211            Span::styled(format!("{icon} "), status_style),
212            Span::styled(base_name.to_string(), header_style),
213        ];
214        if self.status != ToolStatus::Ok {
215            header_spans.push(Span::styled(format!(" [{status_label}]"), status_style));
216        }
217
218        if !args_summary.is_empty() {
219            let header_width: usize = header_spans.iter().map(|s| utils::text_width(&s.text)).sum();
220            if header_width + 2 + utils::text_width(&args_summary) <= self.body_width {
221                header_spans.push(Span::styled("  ", CellStyle::new().bg(self.bg)));
222                header_spans.push(Span::styled(args_summary, muted_style));
223            } else {
224                rows.push(Row::padded(header_spans, self.width, CellStyle::new().bg(self.bg)));
225                for wrapped in super::layout::wrap_text(&args_summary, content_width.saturating_sub(2)) {
226                    let spans = vec![
227                        Span::styled(ENTRY_RAIL, rail_style),
228                        Span::styled("  ", CellStyle::new().bg(self.bg)),
229                        Span::styled(wrapped, muted_style),
230                    ];
231                    rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
232                }
233                header_spans = Vec::new();
234            }
235        }
236        if self.status == ToolStatus::Ok && !self.output.is_empty() && !header_spans.is_empty() {
237            header_spans.push(Span::styled("  ▸", CellStyle::new().fg(p.overlay0).bg(self.bg)));
238        }
239        if !header_spans.is_empty() {
240            rows.push(Row::padded(header_spans, self.width, CellStyle::new().bg(self.bg)));
241        }
242
243        if let Some(summary) = edit_summary_line(self.name, self.output, self.status, self.cwd) {
244            rows.push(Row::padded(
245                vec![
246                    Span::styled(ENTRY_RAIL, rail_style),
247                    Span::styled("   edit  ", CellStyle::new().fg(p.overlay0).bg(self.bg).bold()),
248                    Span::styled(summary, muted_style),
249                ],
250                self.width,
251                CellStyle::new().bg(self.bg),
252            ));
253        }
254
255        if let Some(summary) = diff_summary_line(self.output) {
256            rows.push(Row::padded(
257                vec![
258                    Span::styled(ENTRY_RAIL, rail_style),
259                    Span::styled("   diff  ", CellStyle::new().fg(p.overlay0).bg(self.bg).bold()),
260                    Span::styled(summary, muted_style),
261                ],
262                self.width,
263                CellStyle::new().bg(self.bg),
264            ));
265        }
266
267        if self.status == ToolStatus::Ok {
268            return rows;
269        }
270
271        match lang {
272            Some(lang_str) => {
273                let joined: String = self
274                    .output
275                    .iter()
276                    .take(MAX_TOOL_OUTPUT_LINES)
277                    .map(|line| {
278                        let line = super::path_display::transcript_line(line, self.cwd);
279                        format!("{line}\n")
280                    })
281                    .collect();
282                let highlighted = super::highlight::highlight_lines(&joined, Some(lang_str));
283                for hl_row in highlighted {
284                    let mut spans = vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(GUTTER, gutter_style)];
285                    let content_spans: Vec<_> = hl_row
286                        .into_iter()
287                        .map(|s| Span { text: s.text, style: s.style.bg(self.bg) })
288                        .collect();
289                    spans.extend(super::layout::truncate_spans(
290                        &content_spans,
291                        tool_content_width,
292                        muted_style,
293                    ));
294                    rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
295                }
296            }
297            None => {
298                for line in self.output.iter().take(MAX_TOOL_OUTPUT_LINES) {
299                    let line = super::path_display::transcript_line(line, self.cwd);
300                    let content_style = if is_section_header(&line) {
301                        CellStyle::new().fg(p.overlay1).bg(self.bg).bold()
302                    } else {
303                        CellStyle::new().fg(p.subtext0).bg(self.bg)
304                    };
305                    for wrapped in super::layout::wrap_text_preserving_whitespace(&line, tool_content_width) {
306                        let spans = vec![
307                            Span::styled(ENTRY_RAIL, rail_style),
308                            Span::styled(GUTTER, gutter_style),
309                            Span::styled(wrapped, content_style),
310                        ];
311                        rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
312                    }
313                }
314            }
315        }
316
317        if self.output.len() > MAX_TOOL_OUTPUT_LINES {
318            rows.push(Row::padded(
319                vec![
320                    Span::styled(ENTRY_RAIL, rail_style),
321                    Span::styled(
322                        format!(
323                            "   │ … ({} lines stored, {} shown here)",
324                            self.output.len(),
325                            MAX_TOOL_OUTPUT_LINES
326                        ),
327                        muted_style,
328                    ),
329                ],
330                self.width,
331                CellStyle::new().bg(self.bg),
332            ));
333        }
334
335        rows
336    }
337}
338
339#[derive(Clone, Copy)]
340struct MarkdownTableTheme {
341    cell_style: CellStyle,
342    separator_style: CellStyle,
343    rail_style: CellStyle,
344    bg: Color,
345    width: usize,
346}
347
348struct MarkdownTable {
349    headers: Vec<String>,
350    alignments: Vec<TableAlign>,
351    rows: Vec<Vec<String>>,
352}
353
354impl MarkdownTable {
355    fn new(header: &str, separator: &str) -> Self {
356        Self { headers: Self::parse_cells(header), alignments: Self::parse_alignments(separator), rows: Vec::new() }
357    }
358
359    fn is_valid(&self) -> bool {
360        self.headers.len() >= 2 && self.headers.len() == self.alignments.len()
361    }
362
363    fn push_row(&mut self, row: &str) {
364        let mut cells = Self::parse_cells(row);
365        cells.resize(self.headers.len(), String::new());
366        cells.truncate(self.headers.len());
367        self.rows.push(cells);
368    }
369
370    fn render(&self, rail_style: CellStyle, bg: Color, width: usize, body_width: usize) -> Vec<Row> {
371        let p = super::style::palette();
372        let text_style = CellStyle::new().fg(p.text).bg(bg);
373        let separator_style = CellStyle::new().fg(p.overlay0).bg(bg);
374        let header_theme = MarkdownTableTheme {
375            cell_style: CellStyle::new().fg(p.text).bg(bg).bold().underlined(),
376            separator_style,
377            rail_style,
378            bg,
379            width,
380        };
381        let body_theme = MarkdownTableTheme { cell_style: text_style, separator_style, rail_style, bg, width };
382
383        if body_width <= MIN_TABLE_RENDER_WIDTH {
384            return self.render_narrow(rail_style, text_style, bg, width, body_width);
385        }
386
387        let column_widths = self.column_widths(body_width);
388        if column_widths.contains(&0) {
389            return self.render_narrow(rail_style, text_style, bg, width, body_width);
390        }
391
392        let mut rows = Vec::new();
393        rows.push(Self::row(&self.headers, &self.alignments, &column_widths, header_theme));
394        rows.push(Row::padded(
395            vec![
396                Span::styled(ENTRY_RAIL, rail_style),
397                Span::styled(Self::separator(&column_widths), separator_style),
398            ],
399            width,
400            CellStyle::new().bg(bg),
401        ));
402        for cells in &self.rows {
403            rows.push(Self::row(cells, &self.alignments, &column_widths, body_theme));
404        }
405        if self.rows.is_empty() {
406            rows.push(Row::padded(
407                vec![
408                    Span::styled(ENTRY_RAIL, rail_style),
409                    Span::styled("(no rows)", CellStyle::new().fg(p.subtext0).bg(bg)),
410                ],
411                width,
412                CellStyle::new().bg(bg),
413            ));
414        }
415        rows
416    }
417
418    fn render_narrow(
419        &self, rail_style: CellStyle, text_style: CellStyle, bg: Color, width: usize, body_width: usize,
420    ) -> Vec<Row> {
421        let mut rows = Vec::new();
422        let header = self.headers.join(" / ");
423        for wrapped in super::layout::wrap_text(&header, body_width) {
424            rows.push(Row::padded(
425                vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
426                width,
427                CellStyle::new().bg(bg),
428            ));
429        }
430        for cells in &self.rows {
431            let line = self
432                .headers
433                .iter()
434                .zip(cells.iter())
435                .map(|(header, cell)| format!("{header}: {cell}"))
436                .collect::<Vec<_>>()
437                .join("; ");
438            for wrapped in super::layout::wrap_text(&line, body_width) {
439                rows.push(Row::padded(
440                    vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
441                    width,
442                    CellStyle::new().bg(bg),
443                ));
444            }
445        }
446        rows
447    }
448
449    fn row(cells: &[String], alignments: &[TableAlign], widths: &[usize], theme: MarkdownTableTheme) -> Row {
450        let mut spans = vec![Span::styled(ENTRY_RAIL, theme.rail_style)];
451        for (index, cell) in cells.iter().enumerate() {
452            if index > 0 {
453                spans.push(Span::styled("  ", theme.separator_style));
454            }
455            let text = Self::align_cell(
456                &utils::truncate_ellipsis(cell, widths[index]),
457                widths[index],
458                alignments[index],
459            );
460            spans.push(Span::styled(text, theme.cell_style));
461        }
462        Row::padded(spans, theme.width, CellStyle::new().bg(theme.bg))
463    }
464
465    fn separator(widths: &[usize]) -> String {
466        widths
467            .iter()
468            .map(|width| "─".repeat((*width).max(1)))
469            .collect::<Vec<_>>()
470            .join("  ")
471    }
472
473    fn column_widths(&self, body_width: usize) -> Vec<usize> {
474        let columns = self.headers.len();
475        let separators = columns.saturating_sub(1) * 2;
476        let available = body_width.saturating_sub(separators);
477        if available < columns {
478            return vec![0; columns];
479        }
480
481        let mut desired = self
482            .headers
483            .iter()
484            .enumerate()
485            .map(|(index, header)| {
486                std::iter::once(header)
487                    .chain(self.rows.iter().filter_map(|row| row.get(index)))
488                    .map(|cell| utils::text_width(cell))
489                    .max()
490                    .unwrap_or(1)
491                    .max(3)
492            })
493            .collect::<Vec<_>>();
494        let desired_total: usize = desired.iter().sum();
495        if desired_total <= available {
496            return desired;
497        }
498
499        let mut widths = desired
500            .iter()
501            .map(|desired_width| ((*desired_width * available) / desired_total).max(3))
502            .collect::<Vec<_>>();
503        while widths.iter().sum::<usize>() > available {
504            if let Some((index, _)) = widths
505                .iter()
506                .enumerate()
507                .filter(|(_, width)| **width > 3)
508                .max_by_key(|(_, width)| **width)
509            {
510                widths[index] -= 1;
511            } else {
512                break;
513            }
514        }
515        while widths.iter().sum::<usize>() < available {
516            if let Some((index, _)) = desired
517                .iter_mut()
518                .enumerate()
519                .max_by_key(|(index, desired_width)| desired_width.saturating_sub(widths[*index]))
520            {
521                widths[index] += 1;
522            } else {
523                break;
524            }
525        }
526        widths
527    }
528
529    fn align_cell(text: &str, width: usize, align: TableAlign) -> String {
530        let used = utils::text_width(text);
531        if used >= width {
532            return text.to_string();
533        }
534        let pad = width - used;
535        match align {
536            TableAlign::Left => format!("{text}{}", " ".repeat(pad)),
537            TableAlign::Right => format!("{}{text}", " ".repeat(pad)),
538            TableAlign::Center => {
539                let left = pad / 2;
540                let right = pad - left;
541                format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
542            }
543        }
544    }
545
546    fn parse_cells(line: &str) -> Vec<String> {
547        line.trim()
548            .trim_matches('|')
549            .split('|')
550            .map(|cell| cell.trim().to_string())
551            .collect()
552    }
553
554    fn parse_alignments(line: &str) -> Vec<TableAlign> {
555        Self::parse_cells(line)
556            .into_iter()
557            .map(|cell| {
558                let left = cell.starts_with(':');
559                let right = cell.ends_with(':');
560                match (left, right) {
561                    (true, true) => TableAlign::Center,
562                    (false, true) => TableAlign::Right,
563                    _ => TableAlign::Left,
564                }
565            })
566            .collect()
567    }
568
569    fn is_row(line: &str) -> bool {
570        line.trim().matches('|').count() >= 2
571    }
572
573    fn is_separator(line: &str) -> bool {
574        let cells = Self::parse_cells(line);
575        cells.len() >= 2
576            && cells.iter().all(|cell| {
577                let inner = cell.trim().trim_matches(':');
578                inner.len() >= 3 && inner.chars().all(|ch| ch == '-')
579            })
580    }
581}
582
583impl App {
584    /// Build startup banner rows from app state.
585    pub fn render_banner_rows(&self, width: usize) -> Vec<Row> {
586        let p = super::style::palette();
587        let bg = Color::Reset;
588        let theme = StartupBannerTheme {
589            width,
590            brand_style: CellStyle::new().fg(p.accent).bg(bg).bold(),
591            attention_style: CellStyle::new().fg(p.peach).bg(bg).bold(),
592            muted_style: CellStyle::new().fg(p.subtext0).bg(bg),
593            hint_style: CellStyle::new().fg(p.yellow).bg(bg).bold(),
594            rail_style: CellStyle::new().fg(p.overlay0).bg(bg),
595            success_style: CellStyle::new().fg(p.green).bg(bg),
596            meta_style: CellStyle::new().fg(p.overlay1).bg(bg),
597        };
598        let snapshot = self.self_knowledge_snapshot();
599        let sections = snapshot.startup_sections();
600        let context_lines = self.startup_section_lines(&sections, "Context");
601        let diagnostics = self.startup_section_lines(&sections, "Diagnostics");
602
603        let mut rows = Vec::new();
604        push_banner_brand_row(&mut rows, theme);
605        push_wrapped_banner_text(
606            &mut rows,
607            "Ask for change, run a command, or inspect the repo.",
608            BannerIndent(0, 0),
609            theme,
610            theme.muted_style,
611        );
612        rows.push(Row::blank(width, CellStyle::new()));
613
614        let context_ready = context_lines.iter().any(|line| line != "(none)");
615        let context_text = readiness_context_text(&context_lines);
616        let context_path = context_lines
617            .iter()
618            .find(|line| line.as_str() != "(none)")
619            .map(|line| readiness_context_path(line))
620            .unwrap_or_else(|| "—".to_string());
621        push_banner_readiness_row(&mut rows, &context_text, &context_path, context_ready, theme);
622
623        let skill_count = snapshot.inventory.references.skills.len();
624        let skills_text = match skill_count {
625            0 => "No skills available".to_string(),
626            1 => "1 skill available".to_string(),
627            count => format!("{count} skills available"),
628        };
629        push_banner_readiness_row(&mut rows, &skills_text, "skills", skill_count > 0, theme);
630
631        let search_mode = snapshot.runtime.provider.search.mode.as_str();
632        push_banner_readiness_row(&mut rows, "Web Search", search_mode, true, theme);
633
634        if diagnostics.iter().any(|line| line != "(none)") {
635            rows.push(Row::blank(width, CellStyle::new()));
636            push_banner_attention_heading(&mut rows, theme);
637            for line in diagnostics {
638                push_wrapped_banner_text(&mut rows, &line, BannerIndent(2, 2), theme, theme.muted_style);
639            }
640        }
641
642        rows.push(Row::blank(width, CellStyle::new()));
643        push_banner_help(&mut rows, theme);
644        rows.push(Row::blank(width, CellStyle::new()));
645        rows
646    }
647
648    fn startup_section_lines(&self, sections: &[StartupSection], heading: &str) -> Vec<String> {
649        sections
650            .iter()
651            .find(|section| section.heading == heading)
652            .map(|section| {
653                section
654                    .lines
655                    .iter()
656                    .map(|line| match section.heading {
657                        "Context" | "Diagnostics" => super::path_display::transcript_line(line, &self.cwd),
658                        _ => line.clone(),
659                    })
660                    .collect()
661            })
662            .unwrap_or_default()
663    }
664}
665
666/// Produce a compact, user-facing summary for a tool invocation.
667pub fn summarize_tool_invocation(name: &str, args: &str, cwd: &Path) -> String {
668    let summary = summarize_tool_args(args, cwd);
669    if name != "run_shell" || summary.is_empty() {
670        return summary;
671    }
672
673    let command = summary
674        .strip_prefix("argv: ")
675        .or_else(|| summary.strip_prefix("program: "))
676        .unwrap_or(&summary);
677    format!("$ {command}")
678}
679
680/// Produce a short summary of a tool's arguments for the transcript line.
681pub fn summarize_tool_args(args: &str, cwd: &Path) -> String {
682    let trimmed = args.trim();
683    if trimmed.is_empty() || trimmed == "{}" {
684        return String::new();
685    }
686
687    let v = match serde_json::from_str::<serde_json::Value>(trimmed) {
688        Ok(v) => v,
689        Err(_) => return utils::truncate_ellipsis(trimmed, 48),
690    };
691
692    match v.as_object() {
693        Some(obj) => {
694            if let Some(argv) = obj.get("argv").and_then(|value| value.as_array()) {
695                let command = argv
696                    .iter()
697                    .filter_map(|value| value.as_str())
698                    .map(|value| super::path_display::transcript_line(value, cwd))
699                    .collect::<Vec<_>>()
700                    .join(" ");
701                if !command.is_empty() {
702                    return format!("argv: {}", utils::truncate_ellipsis(&command, 72));
703                }
704            }
705            for key in &["pattern", "path", "query", "root", "glob", "file", "program", "url"] {
706                if let Some(val) = obj.get(*key).and_then(|f| f.as_str()) {
707                    let val = super::path_display::transcript_line(val, cwd);
708                    return format!("{}: {}", key, utils::truncate_ellipsis(&val, 40));
709                }
710            }
711            for (k, val) in obj {
712                if let Some(s) = val.as_str() {
713                    let s = super::path_display::transcript_line(s, cwd);
714                    return format!("{k}: {}", utils::truncate_ellipsis(&s, 40));
715                }
716            }
717            utils::truncate_ellipsis(trimmed, 48)
718        }
719        None => utils::truncate_ellipsis(trimmed, 48),
720    }
721}
722
723fn wrap_with_indent(text: &str, indent: BannerIndent, width: usize) -> Vec<String> {
724    let first_width = width.saturating_sub(indent.first()).max(1);
725    let continuation_width = width.saturating_sub(indent.continuation()).max(1);
726    let mut out = Vec::new();
727    for (index, line) in super::layout::wrap_text(text, first_width).into_iter().enumerate() {
728        if index == 0 {
729            out.push(format!("{}{}", " ".repeat(indent.first()), line));
730        } else {
731            for continued in super::layout::wrap_text(&line, continuation_width) {
732                out.push(format!("{}{}", " ".repeat(indent.continuation()), continued));
733            }
734        }
735    }
736    out
737}
738
739fn readiness_context_text(lines: &[String]) -> String {
740    let visible = lines
741        .iter()
742        .filter(|line| line.as_str() != "(none)")
743        .collect::<Vec<_>>();
744    let Some(first) = visible.first() else {
745        return "No project instructions".to_string();
746    };
747    let path = first.split(" (truncated").next().unwrap_or(first);
748    let name = Path::new(path)
749        .file_name()
750        .and_then(|name| name.to_str())
751        .unwrap_or(path);
752    format!("{name} loaded")
753}
754
755fn readiness_context_path(line: &str) -> String {
756    let path = line.split(" (truncated").next().unwrap_or(line);
757    if Path::new(path).components().count() == 1 { format!("./{path}") } else { path.to_string() }
758}
759
760fn push_banner_brand_row(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
761    rows.push(Row::padded(
762        vec![
763            Span::styled("thndrs", theme.brand_style),
764            Span::styled(" / ready", theme.meta_style.bold()),
765        ],
766        theme.width,
767        CellStyle::new(),
768    ));
769}
770
771fn push_banner_readiness_row(rows: &mut Vec<Row>, text: &str, meta: &str, ready: bool, theme: StartupBannerTheme) {
772    let prefix_width = utils::text_width("│   ✓ ");
773    let available = theme.body_width().saturating_sub(prefix_width).max(1);
774    let wrapped = super::layout::wrap_text(text, available);
775    for (index, line) in wrapped.into_iter().enumerate() {
776        let marker = if index == 0 { if ready { "✓ " } else { "· " } } else { "  " };
777        let marker_style = if ready { theme.success_style } else { theme.muted_style };
778        let mut spans = vec![
779            Span::styled("│   ", theme.rail_style),
780            Span::styled(marker, marker_style),
781            Span::styled(line, theme.muted_style),
782        ];
783        if index == 0 {
784            let used = super::layout::spans_width(&spans);
785            let meta_width = utils::text_width(meta);
786            if used + meta_width + 2 <= theme.body_width() {
787                spans.push(Span::styled(
788                    " ".repeat(theme.body_width() - used - meta_width),
789                    theme.muted_style,
790                ));
791                spans.push(Span::styled(meta, theme.meta_style));
792            }
793        }
794        rows.push(Row::padded(spans, theme.width, CellStyle::new()));
795    }
796}
797
798fn push_banner_attention_heading(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
799    rows.push(Row::padded(
800        vec![Span::styled("ATTENTION", theme.attention_style)],
801        theme.width,
802        CellStyle::new(),
803    ));
804}
805
806fn push_banner_help(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
807    let spans = vec![
808        Span::styled("?", theme.hint_style),
809        Span::styled(" help   ", theme.muted_style),
810        Span::styled("/model", theme.hint_style),
811        Span::styled(" switch   ", theme.muted_style),
812        Span::styled("/search", theme.hint_style),
813        Span::styled(" configure", theme.muted_style),
814    ];
815    for line in super::layout::wrap_spans(&spans, theme.body_width()) {
816        rows.push(Row::padded(line, theme.width, CellStyle::new()));
817    }
818}
819
820fn push_wrapped_banner_text(
821    rows: &mut Vec<Row>, text: &str, indent: BannerIndent, theme: StartupBannerTheme, text_style: CellStyle,
822) {
823    let wrapped = wrap_with_indent(text, indent, theme.body_width());
824    for line in wrapped {
825        rows.push(Row::padded(
826            vec![Span::styled(line, text_style)],
827            theme.width,
828            CellStyle::new(),
829        ));
830    }
831}
832
833fn edit_summary_line(name: &str, output: &[String], status: ToolStatus, cwd: &Path) -> Option<String> {
834    let operation = name.split('#').next().unwrap_or(name);
835    let is_edit_tool = matches!(operation, "create_file" | "replace_range" | "write_patch");
836    if !is_edit_tool
837        && !output
838            .iter()
839            .any(|line| line.contains("wrote") || line.contains("replaced"))
840    {
841        return None;
842    }
843    let path = output
844        .iter()
845        .find_map(|line| path_like_suffix(line))
846        .map(|path| super::path_display::transcript_line(&path, cwd))
847        .unwrap_or_else(|| "(path unavailable)".to_string());
848    Some(format!("{operation} {path} [{}]", status.label()))
849}
850
851fn diff_summary_line(output: &[String]) -> Option<String> {
852    let mut added = 0usize;
853    let mut removed = 0usize;
854    let mut files = Vec::new();
855    for line in output {
856        if let Some(path) = line.strip_prefix("+++ ") {
857            files.push(path.trim_start_matches("b/").to_string());
858        } else if line.starts_with('+') && !line.starts_with("+++") {
859            added += 1;
860        } else if line.starts_with('-') && !line.starts_with("---") {
861            removed += 1;
862        }
863    }
864    if added == 0 && removed == 0 && files.is_empty() {
865        return None;
866    }
867    files.sort();
868    files.dedup();
869    let file_label = match files.as_slice() {
870        [] => "unknown file".to_string(),
871        [file] => file.clone(),
872        _ => format!("{} files", files.len()),
873    };
874    Some(format!("{file_label} +{added} -{removed}"))
875}
876
877fn path_like_suffix(line: &str) -> Option<String> {
878    line.rsplit_once(": ").map(|(_, path)| path.to_string()).or_else(|| {
879        line.split_whitespace()
880            .last()
881            .filter(|part| part.contains('/'))
882            .map(str::to_string)
883    })
884}
885
886/// Convert a single transcript entry to padded rows for scrollback.
887fn entry_to_rows(entry: &Entry, user_label: &str, width: usize, cwd: &Path, tool_group_start: bool) -> Vec<Row> {
888    let p = super::style::palette();
889    let bg = Color::Reset;
890    let body_width = super::layout::content_width(width);
891    let railed_body_width = body_width.saturating_sub(utils::text_width(ENTRY_RAIL));
892
893    match entry {
894        Entry::User { text } => {
895            let rail_style = CellStyle::new().fg(p.blue).bg(bg).bold();
896            let label_style = CellStyle::new().fg(p.blue).bg(bg).bold();
897            let text_style = CellStyle::new().fg(p.subtext0).bg(bg).italic();
898            let mut rows = LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width)
899                .build(user_label, text);
900            rows.push(Row::blank(width, CellStyle::new().bg(bg)));
901            rows
902        }
903        Entry::Agent { text, .. } => {
904            let rail_style = CellStyle::new().fg(p.green).bg(bg).bold();
905            let label_style = CellStyle::new().fg(p.green).bg(bg).bold();
906            assistant_block_rows(text, rail_style, label_style, bg, width, railed_body_width)
907        }
908        Entry::Reasoning { text, streaming } => {
909            let rail_style = CellStyle::new().fg(p.mauve).bg(bg).bold();
910            let label_style = CellStyle::new().fg(p.mauve).bg(bg).bold();
911            let text_style = CellStyle::new().fg(p.subtext0).bg(bg).italic();
912            let label = if *streaming { "Thinking ·" } else { "Thinking ✓" };
913            LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width).build(label, text)
914        }
915        Entry::Tool { name, arguments, status, output } => ToolBlockView {
916            name,
917            args: arguments,
918            status: *status,
919            output,
920            width,
921            body_width,
922            bg,
923            cwd,
924            group_start: tool_group_start,
925        }
926        .rows(),
927        Entry::Status { text } => {
928            let rail_style = CellStyle::new().fg(p.overlay1).bg(bg);
929            let label_style = CellStyle::new().fg(p.overlay1).bg(bg).bold();
930            let text_style = CellStyle::new().fg(p.text).bg(bg);
931            LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width)
932                .build(status_label_for(text), text)
933        }
934        Entry::Error { text } => {
935            let error_bg = p.surface_dim;
936            let rail_style = CellStyle::new().fg(p.red).bg(error_bg).bold();
937            let label_style = CellStyle::new().fg(p.red).bg(error_bg).bold();
938            let text_style = CellStyle::new().fg(p.text).bg(error_bg);
939            LabeledBlock::new(rail_style, label_style, text_style, error_bg, width, railed_body_width)
940                .build("⚠ Error", text)
941        }
942    }
943}
944
945/// Build an assistant message block, detecting markdown code fences for syntax highlighting.
946fn assistant_block_rows(
947    text: &str, rail_style: CellStyle, label_style: CellStyle, bg: Color, width: usize, body_width: usize,
948) -> Vec<Row> {
949    let p = super::style::palette();
950    let text_style = CellStyle::new().fg(p.text).bg(bg);
951    let mut rows = vec![
952        Row::blank(width, CellStyle::new().bg(bg)),
953        Row::padded(
954            vec![
955                Span::styled(ENTRY_RAIL, rail_style),
956                Span::styled("Agent".to_string(), label_style),
957            ],
958            width,
959            CellStyle::new().bg(bg),
960        ),
961    ];
962
963    match assistant_markdown_body(text) {
964        Some(markdown) => rows.extend(render_markdown_body(
965            markdown, rail_style, text_style, bg, width, body_width,
966        )),
967        None => {
968            for line in super::layout::wrap_text(text, body_width) {
969                match line.is_empty() {
970                    true => rows.push(Row::blank(width, CellStyle::new().bg(bg))),
971                    false => rows.push(Row::padded(
972                        vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(line, text_style)],
973                        width,
974                        CellStyle::new().bg(bg),
975                    )),
976                }
977            }
978        }
979    }
980
981    if rows.len() == 2 {
982        rows.push(Row::blank(width, CellStyle::new().bg(bg)));
983    }
984    rows
985}
986
987/// Extract Markdown from an outer provider wrapper or ordinary fenced Markdown.
988fn assistant_markdown_body(text: &str) -> Option<&str> {
989    strip_outer_markdown_fence(text).or_else(|| text.contains("```").then_some(text))
990}
991
992/// Strip a complete or streaming outer Markdown fence without confusing its
993/// contents with a code block.
994fn strip_outer_markdown_fence(text: &str) -> Option<&str> {
995    for (opening, closing) in [
996        ("````markdown\r\n", "\r\n````"),
997        ("````markdown\n", "\n````"),
998        ("````md\r\n", "\r\n````"),
999        ("````md\n", "\n````"),
1000        ("```markdown\r\n", "\r\n```"),
1001        ("```markdown\n", "\n```"),
1002        ("```md\r\n", "\r\n```"),
1003        ("```md\n", "\n```"),
1004    ] {
1005        if let Some(body) = text.strip_prefix(opening) {
1006            return Some(body.strip_suffix(closing).unwrap_or(body));
1007        }
1008    }
1009    None
1010}
1011
1012/// Render markdown body with code fence detection and syntax highlighting.
1013fn render_markdown_body(
1014    markdown: &str, rail_style: CellStyle, text_style: CellStyle, bg: Color, width: usize, body_width: usize,
1015) -> Vec<Row> {
1016    let p = super::style::palette();
1017    let gutter_style = CellStyle::new().fg(p.overlay0).bg(bg);
1018    let code_width = body_width.saturating_sub(utils::text_width(GUTTER));
1019    let mut rows = Vec::new();
1020    let mut in_code_fence = false;
1021    let mut code_lang: Option<String> = None;
1022    let mut code_buf = String::new();
1023    let mut pending_plain = Vec::new();
1024    let mut lines = markdown.lines().peekable();
1025
1026    while let Some(line) = lines.next() {
1027        if line.starts_with("```") {
1028            flush_plain_markdown_lines(
1029                &mut rows,
1030                &mut pending_plain,
1031                rail_style,
1032                text_style,
1033                bg,
1034                width,
1035                body_width,
1036            );
1037            if !in_code_fence {
1038                in_code_fence = true;
1039                let lang_str = line.trim_start_matches('`').trim();
1040                code_lang = if lang_str.is_empty() { None } else { Some(lang_str.to_string()) };
1041                code_buf.clear();
1042            } else {
1043                let lang = code_lang.as_deref();
1044                let highlighted = super::highlight::highlight_lines(&code_buf, lang);
1045                push_highlighted_code_rows(&mut rows, highlighted, rail_style, gutter_style, bg, width, code_width);
1046                in_code_fence = false;
1047                code_lang = None;
1048                code_buf.clear();
1049            }
1050            continue;
1051        }
1052
1053        if in_code_fence {
1054            code_buf.push_str(line);
1055            code_buf.push('\n');
1056            continue;
1057        }
1058
1059        if MarkdownTable::is_separator(line)
1060            && !pending_plain.is_empty()
1061            && let Some(header) = pending_plain.pop()
1062        {
1063            let mut table = MarkdownTable::new(&header, line);
1064            while let Some(peeked) = lines.peek() {
1065                if !MarkdownTable::is_row(peeked) {
1066                    break;
1067                }
1068                match lines.next() {
1069                    Some(row_line) => table.push_row(row_line),
1070                    None => break,
1071                }
1072            }
1073            if table.is_valid() {
1074                rows.extend(table.render(rail_style, bg, width, body_width));
1075                continue;
1076            }
1077            pending_plain.push(header);
1078            pending_plain.push(line.to_string());
1079        } else {
1080            pending_plain.push(line.to_string());
1081        }
1082    }
1083
1084    flush_plain_markdown_lines(
1085        &mut rows,
1086        &mut pending_plain,
1087        rail_style,
1088        text_style,
1089        bg,
1090        width,
1091        body_width,
1092    );
1093
1094    if in_code_fence && !code_buf.is_empty() {
1095        let lang = code_lang.as_deref();
1096        let highlighted = super::highlight::highlight_lines(&code_buf, lang);
1097        push_highlighted_code_rows(&mut rows, highlighted, rail_style, gutter_style, bg, width, code_width);
1098    }
1099
1100    if rows.is_empty() {
1101        rows.push(Row::blank(width, CellStyle::new().bg(bg)));
1102    }
1103
1104    rows
1105}
1106
1107/// Append syntax-highlighted code rows, hard-wrapping oversized lines while
1108/// preserving the highlighter's spans and styles on each continuation row.
1109fn push_highlighted_code_rows(
1110    rows: &mut Vec<Row>, highlighted: Vec<Vec<Span>>, rail_style: CellStyle, gutter_style: CellStyle, bg: Color,
1111    width: usize, code_width: usize,
1112) {
1113    for highlighted_line in highlighted {
1114        let content_spans = highlighted_line
1115            .into_iter()
1116            .map(|span| Span { text: span.text, style: span.style.bg(bg) })
1117            .collect::<Vec<_>>();
1118        for wrapped in super::layout::wrap_spans(&content_spans, code_width) {
1119            let mut spans = vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(GUTTER, gutter_style)];
1120            spans.extend(wrapped);
1121            rows.push(Row::padded(spans, width, CellStyle::new().bg(bg)));
1122        }
1123    }
1124}
1125
1126fn flush_plain_markdown_lines(
1127    rows: &mut Vec<Row>, pending: &mut Vec<String>, rail_style: CellStyle, text_style: CellStyle, bg: Color,
1128    width: usize, body_width: usize,
1129) {
1130    for line in pending.drain(..) {
1131        if line.is_empty() {
1132            rows.push(Row::blank(width, CellStyle::new().bg(bg)));
1133        } else {
1134            for wrapped in super::layout::wrap_text(&line, body_width) {
1135                rows.push(Row::padded(
1136                    vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
1137                    width,
1138                    CellStyle::new().bg(bg),
1139                ));
1140            }
1141        }
1142    }
1143}
1144
1145/// Detect whether a tool output line is a section header.
1146fn is_section_header(line: &str) -> bool {
1147    let trimmed = line.trim();
1148    trimmed.starts_with("── ") || trimmed.starts_with("$ ")
1149}
1150
1151/// Derive a label for status entries based on text content.
1152fn status_label_for(text: &str) -> &'static str {
1153    if text.starts_with("context  ") {
1154        "Context"
1155    } else if text.starts_with("logs  ") {
1156        "Session log"
1157    } else if text.starts_with("provider:") || text.starts_with("tool budget:") {
1158        "Diagnostic"
1159    } else if text.starts_with("queued ") {
1160        "Queued"
1161    } else if text.starts_with("queue target:") {
1162        "Queue"
1163    } else if text.starts_with("background ") || text == "no background processes" {
1164        "Background"
1165    } else if text == "cancelled" {
1166        "Cancelled"
1167    } else {
1168        "System"
1169    }
1170}
1171
1172#[cfg(test)]
1173mod tests;