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::mark_buffer_hyperlinks;
17use crate::terminal::hyperlinks::visible_lines;
18use crate::terminal::hyperlinks::HyperlinkLine;
19use crate::terminal::palette::ColorCapabilities;
20
21use super::ansi::strip_terminal_controls;
22use super::diff::looks_like_diff;
23use super::diff::render_diff;
24use super::markdown::render_markdown;
25use super::CellState;
26use super::TranscriptCell;
27use super::TranscriptKind;
28use super::TranscriptModel;
29
30/// Pure, width-aware transcript renderer.
31pub struct TranscriptRenderer<'a> {
32    model: &'a TranscriptModel,
33    capabilities: ColorCapabilities,
34}
35
36impl<'a> TranscriptRenderer<'a> {
37    pub fn new(model: &'a TranscriptModel, capabilities: ColorCapabilities) -> Self {
38        Self {
39            model,
40            capabilities,
41        }
42    }
43
44    /// Render visible lines and semantic hyperlink annotations at `width`.
45    pub fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
46        let width = usize::from(width.max(1));
47        let mut lines = Vec::new();
48        for (index, cell) in self.model.cells().iter().enumerate() {
49            if index > 0 {
50                lines.push(HyperlinkLine::from(Line::default()));
51            }
52            let cell_lines = self.render_cell(cell, width);
53            lines.extend(annotate_web_urls(cell_lines));
54        }
55        lines
56    }
57
58    /// Render into a Ratatui buffer and attach OSC-8 metadata after layout so
59    /// terminal control bytes never participate in width calculations.
60    pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
61        let lines = self.lines(area.width);
62        render_hyperlink_lines(area, buffer, &lines);
63    }
64
65    /// Render the newest transcript rows, preserving their hyperlink
66    /// annotations. The real app follows the tail, so this must not project
67    /// annotations from rows that were clipped above the viewport.
68    pub fn render_tail_buffer(&self, area: Rect, buffer: &mut Buffer) {
69        let lines = self.lines(area.width);
70        let start = lines.len().saturating_sub(usize::from(area.height));
71        render_hyperlink_lines(area, buffer, &lines[start..]);
72    }
73
74    fn render_cell(&self, cell: &TranscriptCell, width: usize) -> Vec<Line<'static>> {
75        let mut lines = vec![self.header(cell)];
76        if cell.body.is_empty() {
77            return lines;
78        }
79
80        let body_width = width.saturating_sub(2).max(1);
81        let sanitized = strip_terminal_controls(&cell.body);
82        let body = if cell.kind == TranscriptKind::Assistant {
83            render_markdown(&sanitized, body_width, self.capabilities)
84        } else if matches!(cell.kind, TranscriptKind::Patch | TranscriptKind::FileWrite)
85            && looks_like_diff(&sanitized)
86        {
87            render_diff(&sanitized, self.capabilities)
88                .into_iter()
89                .flat_map(|line| adaptive_wrap_lines([line], RtOptions::new(body_width)))
90                .collect()
91        } else {
92            render_plain(&sanitized, body_width, body_style(cell, self.capabilities))
93        };
94        lines.extend(body.into_iter().map(prefix_body_line));
95        lines
96    }
97
98    fn header(&self, cell: &TranscriptCell) -> Line<'static> {
99        let (icon, state_style) = match cell.state {
100            CellState::Pending => (
101                "◌",
102                Style::default().fg(self.capabilities.best_color((225, 180, 80))),
103            ),
104            CellState::Complete => (
105                "✓",
106                Style::default().fg(self.capabilities.best_color((80, 200, 120))),
107            ),
108            CellState::Failed => (
109                "✗",
110                Style::default().fg(self.capabilities.best_color((235, 100, 100))),
111            ),
112        };
113        let title_style = Style::default()
114            .fg(kind_color(cell.kind, self.capabilities))
115            .add_modifier(Modifier::BOLD);
116        Line::from(vec![
117            Span::styled(icon.to_string(), state_style),
118            Span::raw(" "),
119            Span::styled(bounded_terminal_metadata(&cell.title, 256), title_style),
120        ])
121    }
122}
123
124fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
125    let lines = lines.to_vec();
126    Paragraph::new(visible_lines(lines.clone())).render(area, buffer);
127    mark_buffer_hyperlinks(buffer, area, &lines, 0);
128}
129
130impl Widget for TranscriptRenderer<'_> {
131    fn render(self, area: Rect, buffer: &mut Buffer) {
132        self.render_buffer(area, buffer);
133    }
134}
135
136fn render_plain(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
137    let lines = text
138        .split('\n')
139        .map(|line| Line::from(Span::styled(line.to_string(), style)))
140        .collect::<Vec<_>>();
141    adaptive_wrap_lines(lines, RtOptions::new(width))
142}
143
144fn prefix_body_line(line: Line<'static>) -> Line<'static> {
145    if line.spans.is_empty() || line.width() == 0 {
146        return Line::default();
147    }
148    let mut spans = Vec::with_capacity(line.spans.len() + 1);
149    spans.push(Span::raw("  "));
150    spans.extend(line.spans);
151    Line::from(spans).style(line.style)
152}
153
154fn body_style(cell: &TranscriptCell, capabilities: ColorCapabilities) -> Style {
155    match cell.kind {
156        TranscriptKind::Reasoning | TranscriptKind::Usage => Style::default()
157            .fg(capabilities.best_color((145, 150, 160)))
158            .add_modifier(Modifier::ITALIC),
159        TranscriptKind::Notice => Style::default().fg(capabilities.best_color((225, 180, 80))),
160        TranscriptKind::Generic => Style::default().fg(capabilities.best_color((160, 165, 175))),
161        _ => Style::default(),
162    }
163}
164
165fn kind_color(kind: TranscriptKind, capabilities: ColorCapabilities) -> ratatui::style::Color {
166    let rgb = match kind {
167        TranscriptKind::User => (120, 190, 255),
168        TranscriptKind::Assistant => (175, 140, 245),
169        TranscriptKind::Shell => (100, 200, 190),
170        TranscriptKind::FileRead | TranscriptKind::FileWrite | TranscriptKind::Patch => {
171            (100, 175, 230)
172        }
173        TranscriptKind::Mcp => (205, 145, 235),
174        TranscriptKind::Approval => (235, 175, 80),
175        TranscriptKind::Subagent => (110, 205, 155),
176        TranscriptKind::Scheduled => (90, 190, 210),
177        TranscriptKind::Reduction => (225, 150, 95),
178        TranscriptKind::Reasoning => (155, 155, 175),
179        TranscriptKind::Notice => (225, 180, 80),
180        TranscriptKind::Usage => (140, 145, 155),
181        TranscriptKind::Generic => (160, 165, 175),
182    };
183    capabilities.best_color(rgb)
184}
185
186#[cfg(test)]
187mod tests {
188    use ratatui::style::Color;
189    use serde_json::json;
190    use supercode::frontend::FrontendEvent;
191
192    use crate::terminal::palette::ColorLevel;
193
194    use super::*;
195
196    fn capabilities() -> ColorCapabilities {
197        ColorCapabilities {
198            level: ColorLevel::TrueColor,
199            color_enabled: true,
200        }
201    }
202
203    fn event(sequence: u64, kind: &str, payload: serde_json::Value) -> FrontendEvent {
204        FrontendEvent {
205            sequence,
206            kind: kind.into(),
207            payload,
208        }
209    }
210
211    fn visible(lines: &[HyperlinkLine]) -> String {
212        lines
213            .iter()
214            .map(|line| {
215                line.line
216                    .spans
217                    .iter()
218                    .map(|span| span.content.as_ref())
219                    .collect::<String>()
220            })
221            .collect::<Vec<_>>()
222            .join("\n")
223    }
224
225    #[test]
226    fn ansi_tool_output_is_sanitized_and_semantic_header_remains() {
227        let mut model = TranscriptModel::default();
228        model.apply_event(&event(
229            1,
230            "tool_call_completed",
231            json!({"type":"tool_call_completed", "id":"1", "name":"exec_command", "output":"\u{1b}[31mfailed\u{1b}[0m", "is_error":true}),
232        ));
233        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
234        assert_eq!(rendered, "✗ Command: exec_command\n  failed");
235        assert!(!rendered.contains('\u{1b}'));
236    }
237
238    #[test]
239    fn resize_reflows_without_changing_content_or_cell_identity() {
240        let mut model = TranscriptModel::default();
241        model.apply_event(&event(
242            1,
243            "text_delta",
244            json!({"type":"text_delta", "text":"A long assistant sentence reflows when the terminal becomes narrow."}),
245        ));
246        let renderer = TranscriptRenderer::new(&model, capabilities());
247        let narrow = visible(&renderer.lines(28));
248        let wide = visible(&renderer.lines(80));
249        assert!(narrow.lines().count() > wide.lines().count());
250        assert_eq!(model.cells().len(), 1);
251        assert_eq!(model.cells()[0].state, CellState::Pending);
252        assert!(narrow.contains("A long assistant sentence"));
253        assert!(wide.contains("A long assistant sentence reflows"));
254    }
255
256    #[test]
257    fn semantic_hyperlink_marks_buffer_without_affecting_visible_geometry() {
258        let mut model = TranscriptModel::default();
259        model.apply_event(&event(
260            1,
261            "text_delta",
262            json!({"type":"text_delta", "text":"See https://example.com/path"}),
263        ));
264        let renderer = TranscriptRenderer::new(&model, capabilities());
265        let area = Rect::new(0, 0, 40, 3);
266        let mut buffer = Buffer::empty(area);
267        renderer.render_buffer(area, &mut buffer);
268        assert_eq!(buffer[(0, 0)].symbol(), "◌");
269        assert!(buffer[(6, 1)]
270            .symbol()
271            .contains("\x1b]8;;https://example.com/path"));
272        assert_eq!(buffer[(6, 1)].fg, Color::Reset);
273    }
274
275    #[test]
276    fn normalized_coding_agent_event_gallery_has_semantic_cells() {
277        let tools = [
278            ("exec_command", "Command: exec_command"),
279            ("read_file", "Read: read_file"),
280            ("write_file", "Write: write_file"),
281            ("apply_patch", "Patch: apply_patch"),
282            ("mcp__db__query", "MCP: mcp__db__query"),
283            ("spawn_agent", "Subagent: spawn_agent"),
284            ("cron_create", "Scheduled: cron_create"),
285            ("expand_reduction", "Reduced context"),
286        ];
287        let mut model = TranscriptModel::default();
288        let mut sequence = 1u64;
289        for (index, (name, _)) in tools.iter().enumerate() {
290            model.apply_event(&event(
291                sequence,
292                "tool_call_completed",
293                json!({
294                    "type":"tool_call_completed",
295                    "id":format!("gallery-{index}"),
296                    "name":name,
297                    "output":format!("output-{index}"),
298                    "is_error":false
299                }),
300            ));
301            sequence += 1;
302        }
303        model.apply_event(&event(
304            sequence,
305            "tool_call_completed",
306            json!({"type":"tool_call_completed", "id":"failed", "name":"exec_command", "output":"permission denied", "is_error":true}),
307        ));
308        sequence += 1;
309        model.apply_event(&event(
310            sequence,
311            "request",
312            json!({"type":"request", "request":{"kind":"approval", "payload":{"tool":"exec_command"}}}),
313        ));
314        sequence += 1;
315        model.apply_event(&event(
316            sequence,
317            "scheduled_prompt_started",
318            json!({"type":"scheduled_prompt_started", "name":"nightly"}),
319        ));
320
321        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(120));
322        for (_, title) in tools {
323            assert!(rendered.contains(title), "missing semantic cell {title}");
324        }
325        assert!(rendered.contains("✗ Command: exec_command\n  permission denied"));
326        assert!(rendered.contains("◌ Approval requested"));
327        assert!(rendered.contains("✓ scheduled prompt started"));
328    }
329
330    #[test]
331    fn every_transcript_display_field_strips_terminal_controls() {
332        let mut model = TranscriptModel::default();
333        model.apply_event(&event(
334            1,
335            "tool_call_completed",
336            json!({
337                "id":"1",
338                "name":"\u{1b}]0;title\u{7}danger\u{1b}[31m-name",
339                "output":"safe\u{1b}]8;;https://evil.invalid\u{7}label\u{1b}]8;;\u{7}",
340                "is_error":false
341            }),
342        ));
343        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
344        assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
345        assert!(rendered.contains("danger-name"), "{rendered}");
346        assert!(rendered.contains("safelabel"), "{rendered}");
347    }
348}