Skip to main content

supercode_frontend_tui/transcript/
render.rs

1//! Ratatui projection of normalized transcript cells.
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::Modifier;
6use ratatui::style::Style;
7use ratatui::text::Line;
8use ratatui::text::Span;
9use ratatui::widgets::Paragraph;
10use ratatui::widgets::Widget;
11
12use crate::foundation::wrapping::adaptive_wrap_lines;
13use crate::foundation::wrapping::RtOptions;
14use crate::human::bounded_terminal_metadata;
15use crate::terminal::hyperlinks::annotate_web_urls;
16use crate::terminal::hyperlinks::visible_lines;
17use crate::terminal::hyperlinks::HyperlinkLine;
18use crate::terminal::palette::ColorCapabilities;
19
20use super::ansi::strip_terminal_controls;
21use super::diff::looks_like_diff;
22use super::diff::render_diff;
23use super::markdown::render_markdown;
24use super::CellState;
25use super::TranscriptCell;
26use super::TranscriptKind;
27use super::TranscriptModel;
28
29/// Pure, width-aware transcript renderer.
30pub struct TranscriptRenderer<'a> {
31    model: &'a TranscriptModel,
32    capabilities: ColorCapabilities,
33}
34
35impl<'a> TranscriptRenderer<'a> {
36    pub fn new(model: &'a TranscriptModel, capabilities: ColorCapabilities) -> Self {
37        Self {
38            model,
39            capabilities,
40        }
41    }
42
43    /// Render visible lines and semantic hyperlink annotations at `width`.
44    pub fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
45        let width = usize::from(width.max(1));
46        let mut lines = Vec::new();
47        for (index, cell) in self.model.cells().iter().enumerate() {
48            if index > 0 {
49                lines.push(HyperlinkLine::from(Line::default()));
50            }
51            let cell_lines = self.render_cell(cell, width);
52            lines.extend(annotate_web_urls(cell_lines));
53        }
54        lines
55    }
56
57    /// Render into a Ratatui buffer and attach OSC-8 metadata after layout so
58    /// terminal control bytes never participate in width calculations.
59    pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
60        let lines = self.lines(area.width);
61        render_hyperlink_lines(area, buffer, &lines);
62    }
63
64    /// Render the newest transcript rows, preserving their hyperlink
65    /// annotations. The real app follows the tail, so this must not project
66    /// annotations from rows that were clipped above the viewport.
67    pub fn render_tail_buffer(&self, area: Rect, buffer: &mut Buffer) {
68        let lines = self.lines(area.width);
69        let start = lines.len().saturating_sub(usize::from(area.height));
70        render_hyperlink_lines(area, buffer, &lines[start..]);
71    }
72
73    fn render_cell(&self, cell: &TranscriptCell, width: usize) -> Vec<Line<'static>> {
74        let mut lines = vec![self.header(cell)];
75        if cell.body.is_empty() {
76            return lines;
77        }
78
79        let body_width = width.saturating_sub(2).max(1);
80        let sanitized = strip_terminal_controls(&cell.body);
81        let body = if cell.kind == TranscriptKind::Assistant {
82            render_markdown(&sanitized, body_width, self.capabilities)
83        } else if matches!(cell.kind, TranscriptKind::Patch | TranscriptKind::FileWrite)
84            && looks_like_diff(&sanitized)
85        {
86            render_diff(&sanitized, self.capabilities)
87                .into_iter()
88                .flat_map(|line| adaptive_wrap_lines([line], RtOptions::new(body_width)))
89                .collect()
90        } else if folds_output(cell.kind) {
91            fold(
92                render_plain(&sanitized, body_width, body_style(cell, self.capabilities)),
93                self.capabilities,
94            )
95        } else {
96            render_plain(&sanitized, body_width, body_style(cell, self.capabilities))
97        };
98        lines.extend(body.into_iter().map(prefix_body_line));
99        lines
100    }
101
102    fn header(&self, cell: &TranscriptCell) -> Line<'static> {
103        let (icon, state_style) = match cell.state {
104            CellState::Pending => (
105                "◌",
106                Style::default().fg(self.capabilities.best_color((225, 180, 80))),
107            ),
108            CellState::Complete => (
109                "✓",
110                Style::default().fg(self.capabilities.best_color((80, 200, 120))),
111            ),
112            CellState::Failed => (
113                "✗",
114                Style::default().fg(self.capabilities.best_color((235, 100, 100))),
115            ),
116        };
117        let title_style = Style::default()
118            .fg(kind_color(cell.kind, self.capabilities))
119            .add_modifier(Modifier::BOLD);
120        Line::from(vec![
121            Span::styled(icon.to_string(), state_style),
122            Span::raw(" "),
123            Span::styled(bounded_terminal_metadata(&cell.title, 256), title_style),
124        ])
125    }
126}
127
128/// The text only. An OSC 8 escape written into a cell's symbol counts its
129/// destination's characters toward the cell's width in ratatui's diff, which
130/// then skips the cells after it: a bare URL drew as its first letter. The
131/// terminal finds a bare URL in the text on its own.
132fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
133    Paragraph::new(visible_lines(lines.to_vec())).render(area, buffer);
134}
135
136impl Widget for TranscriptRenderer<'_> {
137    fn render(self, area: Rect, buffer: &mut Buffer) {
138        self.render_buffer(area, buffer);
139    }
140}
141
142fn render_plain(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
143    let lines = text
144        .split('\n')
145        .map(|line| Line::from(Span::styled(line.to_string(), style)))
146        .collect::<Vec<_>>();
147    adaptive_wrap_lines(lines, RtOptions::new(width))
148}
149
150/// Tool output shows its head, as the harness's own pane does; the model keeps
151/// every byte of it.
152const FOLDED_OUTPUT_LINES: usize = 6;
153
154fn folds_output(kind: TranscriptKind) -> bool {
155    matches!(
156        kind,
157        TranscriptKind::Shell
158            | TranscriptKind::FileRead
159            | TranscriptKind::FileWrite
160            | TranscriptKind::Patch
161            | TranscriptKind::Mcp
162            | TranscriptKind::Subagent
163            | TranscriptKind::Scheduled
164            | TranscriptKind::Reduction
165            | TranscriptKind::Generic
166    )
167}
168
169fn fold(mut lines: Vec<Line<'static>>, capabilities: ColorCapabilities) -> Vec<Line<'static>> {
170    // Folding one line away saves nothing; show it instead of the count.
171    if lines.len() <= FOLDED_OUTPUT_LINES + 1 {
172        return lines;
173    }
174    let hidden = lines.len() - FOLDED_OUTPUT_LINES;
175    lines.truncate(FOLDED_OUTPUT_LINES);
176    lines.push(Line::from(Span::styled(
177        format!("… +{hidden} lines"),
178        Style::default()
179            .fg(capabilities.best_color((145, 150, 160)))
180            .add_modifier(Modifier::ITALIC),
181    )));
182    lines
183}
184
185fn prefix_body_line(line: Line<'static>) -> Line<'static> {
186    if line.spans.is_empty() || line.width() == 0 {
187        return Line::default();
188    }
189    let mut spans = Vec::with_capacity(line.spans.len() + 1);
190    spans.push(Span::raw("  "));
191    spans.extend(line.spans);
192    Line::from(spans).style(line.style)
193}
194
195fn body_style(cell: &TranscriptCell, capabilities: ColorCapabilities) -> Style {
196    match cell.kind {
197        TranscriptKind::Reasoning | TranscriptKind::Usage => Style::default()
198            .fg(capabilities.best_color((145, 150, 160)))
199            .add_modifier(Modifier::ITALIC),
200        TranscriptKind::Notice => Style::default().fg(capabilities.best_color((225, 180, 80))),
201        TranscriptKind::Generic => Style::default().fg(capabilities.best_color((160, 165, 175))),
202        _ => Style::default(),
203    }
204}
205
206fn kind_color(kind: TranscriptKind, capabilities: ColorCapabilities) -> ratatui::style::Color {
207    let rgb = match kind {
208        TranscriptKind::User => (120, 190, 255),
209        TranscriptKind::Assistant => (175, 140, 245),
210        TranscriptKind::Shell => (100, 200, 190),
211        TranscriptKind::FileRead | TranscriptKind::FileWrite | TranscriptKind::Patch => {
212            (100, 175, 230)
213        }
214        TranscriptKind::Mcp => (205, 145, 235),
215        TranscriptKind::Approval => (235, 175, 80),
216        TranscriptKind::Subagent => (110, 205, 155),
217        TranscriptKind::Scheduled => (90, 190, 210),
218        TranscriptKind::Reduction => (225, 150, 95),
219        TranscriptKind::Reasoning => (155, 155, 175),
220        TranscriptKind::Notice => (225, 180, 80),
221        TranscriptKind::Usage => (140, 145, 155),
222        TranscriptKind::Generic => (160, 165, 175),
223    };
224    capabilities.best_color(rgb)
225}