supercode-frontend-tui 0.4.6

Attachable terminal frontend primitives for Supercode SDK runtimes.
Documentation
//! Ratatui projection of normalized transcript cells.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;

use crate::foundation::wrapping::adaptive_wrap_lines;
use crate::foundation::wrapping::RtOptions;
use crate::human::bounded_terminal_metadata;
use crate::terminal::hyperlinks::annotate_web_urls;
use crate::terminal::hyperlinks::mark_buffer_hyperlinks;
use crate::terminal::hyperlinks::visible_lines;
use crate::terminal::hyperlinks::HyperlinkLine;
use crate::terminal::palette::ColorCapabilities;

use super::ansi::strip_terminal_controls;
use super::diff::looks_like_diff;
use super::diff::render_diff;
use super::markdown::render_markdown;
use super::CellState;
use super::TranscriptCell;
use super::TranscriptKind;
use super::TranscriptModel;

/// Pure, width-aware transcript renderer.
pub struct TranscriptRenderer<'a> {
    model: &'a TranscriptModel,
    capabilities: ColorCapabilities,
}

impl<'a> TranscriptRenderer<'a> {
    pub fn new(model: &'a TranscriptModel, capabilities: ColorCapabilities) -> Self {
        Self {
            model,
            capabilities,
        }
    }

    /// Render visible lines and semantic hyperlink annotations at `width`.
    pub fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
        let width = usize::from(width.max(1));
        let mut lines = Vec::new();
        for (index, cell) in self.model.cells().iter().enumerate() {
            if index > 0 {
                lines.push(HyperlinkLine::from(Line::default()));
            }
            let cell_lines = self.render_cell(cell, width);
            lines.extend(annotate_web_urls(cell_lines));
        }
        lines
    }

    /// Render into a Ratatui buffer and attach OSC-8 metadata after layout so
    /// terminal control bytes never participate in width calculations.
    pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
        let lines = self.lines(area.width);
        render_hyperlink_lines(area, buffer, &lines);
    }

    /// Render the newest transcript rows, preserving their hyperlink
    /// annotations. The real app follows the tail, so this must not project
    /// annotations from rows that were clipped above the viewport.
    pub fn render_tail_buffer(&self, area: Rect, buffer: &mut Buffer) {
        let lines = self.lines(area.width);
        let start = lines.len().saturating_sub(usize::from(area.height));
        render_hyperlink_lines(area, buffer, &lines[start..]);
    }

    fn render_cell(&self, cell: &TranscriptCell, width: usize) -> Vec<Line<'static>> {
        let mut lines = vec![self.header(cell)];
        if cell.body.is_empty() {
            return lines;
        }

        let body_width = width.saturating_sub(2).max(1);
        let sanitized = strip_terminal_controls(&cell.body);
        let body = if cell.kind == TranscriptKind::Assistant {
            render_markdown(&sanitized, body_width, self.capabilities)
        } else if matches!(cell.kind, TranscriptKind::Patch | TranscriptKind::FileWrite)
            && looks_like_diff(&sanitized)
        {
            render_diff(&sanitized, self.capabilities)
                .into_iter()
                .flat_map(|line| adaptive_wrap_lines([line], RtOptions::new(body_width)))
                .collect()
        } else {
            render_plain(&sanitized, body_width, body_style(cell, self.capabilities))
        };
        lines.extend(body.into_iter().map(prefix_body_line));
        lines
    }

    fn header(&self, cell: &TranscriptCell) -> Line<'static> {
        let (icon, state_style) = match cell.state {
            CellState::Pending => (
                "",
                Style::default().fg(self.capabilities.best_color((225, 180, 80))),
            ),
            CellState::Complete => (
                "",
                Style::default().fg(self.capabilities.best_color((80, 200, 120))),
            ),
            CellState::Failed => (
                "",
                Style::default().fg(self.capabilities.best_color((235, 100, 100))),
            ),
        };
        let title_style = Style::default()
            .fg(kind_color(cell.kind, self.capabilities))
            .add_modifier(Modifier::BOLD);
        Line::from(vec![
            Span::styled(icon.to_string(), state_style),
            Span::raw(" "),
            Span::styled(bounded_terminal_metadata(&cell.title, 256), title_style),
        ])
    }
}

fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
    let lines = lines.to_vec();
    Paragraph::new(visible_lines(lines.clone())).render(area, buffer);
    mark_buffer_hyperlinks(buffer, area, &lines, 0);
}

impl Widget for TranscriptRenderer<'_> {
    fn render(self, area: Rect, buffer: &mut Buffer) {
        self.render_buffer(area, buffer);
    }
}

fn render_plain(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
    let lines = text
        .split('\n')
        .map(|line| Line::from(Span::styled(line.to_string(), style)))
        .collect::<Vec<_>>();
    adaptive_wrap_lines(lines, RtOptions::new(width))
}

fn prefix_body_line(line: Line<'static>) -> Line<'static> {
    if line.spans.is_empty() || line.width() == 0 {
        return Line::default();
    }
    let mut spans = Vec::with_capacity(line.spans.len() + 1);
    spans.push(Span::raw("  "));
    spans.extend(line.spans);
    Line::from(spans).style(line.style)
}

fn body_style(cell: &TranscriptCell, capabilities: ColorCapabilities) -> Style {
    match cell.kind {
        TranscriptKind::Reasoning | TranscriptKind::Usage => Style::default()
            .fg(capabilities.best_color((145, 150, 160)))
            .add_modifier(Modifier::ITALIC),
        TranscriptKind::Notice => Style::default().fg(capabilities.best_color((225, 180, 80))),
        TranscriptKind::Generic => Style::default().fg(capabilities.best_color((160, 165, 175))),
        _ => Style::default(),
    }
}

fn kind_color(kind: TranscriptKind, capabilities: ColorCapabilities) -> ratatui::style::Color {
    let rgb = match kind {
        TranscriptKind::User => (120, 190, 255),
        TranscriptKind::Assistant => (175, 140, 245),
        TranscriptKind::Shell => (100, 200, 190),
        TranscriptKind::FileRead | TranscriptKind::FileWrite | TranscriptKind::Patch => {
            (100, 175, 230)
        }
        TranscriptKind::Mcp => (205, 145, 235),
        TranscriptKind::Approval => (235, 175, 80),
        TranscriptKind::Subagent => (110, 205, 155),
        TranscriptKind::Scheduled => (90, 190, 210),
        TranscriptKind::Reduction => (225, 150, 95),
        TranscriptKind::Reasoning => (155, 155, 175),
        TranscriptKind::Notice => (225, 180, 80),
        TranscriptKind::Usage => (140, 145, 155),
        TranscriptKind::Generic => (160, 165, 175),
    };
    capabilities.best_color(rgb)
}

#[cfg(test)]
mod tests {
    use ratatui::style::Color;
    use serde_json::json;
    use supercode::frontend::FrontendEvent;

    use crate::terminal::palette::ColorLevel;

    use super::*;

    fn capabilities() -> ColorCapabilities {
        ColorCapabilities {
            level: ColorLevel::TrueColor,
            color_enabled: true,
        }
    }

    fn event(sequence: u64, kind: &str, payload: serde_json::Value) -> FrontendEvent {
        FrontendEvent {
            sequence,
            kind: kind.into(),
            payload,
        }
    }

    fn visible(lines: &[HyperlinkLine]) -> String {
        lines
            .iter()
            .map(|line| {
                line.line
                    .spans
                    .iter()
                    .map(|span| span.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn ansi_tool_output_is_sanitized_and_semantic_header_remains() {
        let mut model = TranscriptModel::default();
        model.apply_event(&event(
            1,
            "tool_call_completed",
            json!({"type":"tool_call_completed", "id":"1", "name":"exec_command", "output":"\u{1b}[31mfailed\u{1b}[0m", "is_error":true}),
        ));
        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
        assert_eq!(rendered, "✗ Command: exec_command\n  failed");
        assert!(!rendered.contains('\u{1b}'));
    }

    #[test]
    fn resize_reflows_without_changing_content_or_cell_identity() {
        let mut model = TranscriptModel::default();
        model.apply_event(&event(
            1,
            "text_delta",
            json!({"type":"text_delta", "text":"A long assistant sentence reflows when the terminal becomes narrow."}),
        ));
        let renderer = TranscriptRenderer::new(&model, capabilities());
        let narrow = visible(&renderer.lines(28));
        let wide = visible(&renderer.lines(80));
        assert!(narrow.lines().count() > wide.lines().count());
        assert_eq!(model.cells().len(), 1);
        assert_eq!(model.cells()[0].state, CellState::Pending);
        assert!(narrow.contains("A long assistant sentence"));
        assert!(wide.contains("A long assistant sentence reflows"));
    }

    #[test]
    fn semantic_hyperlink_marks_buffer_without_affecting_visible_geometry() {
        let mut model = TranscriptModel::default();
        model.apply_event(&event(
            1,
            "text_delta",
            json!({"type":"text_delta", "text":"See https://example.com/path"}),
        ));
        let renderer = TranscriptRenderer::new(&model, capabilities());
        let area = Rect::new(0, 0, 40, 3);
        let mut buffer = Buffer::empty(area);
        renderer.render_buffer(area, &mut buffer);
        assert_eq!(buffer[(0, 0)].symbol(), "");
        assert!(buffer[(6, 1)]
            .symbol()
            .contains("\x1b]8;;https://example.com/path"));
        assert_eq!(buffer[(6, 1)].fg, Color::Reset);
    }

    #[test]
    fn normalized_coding_agent_event_gallery_has_semantic_cells() {
        let tools = [
            ("exec_command", "Command: exec_command"),
            ("read_file", "Read: read_file"),
            ("write_file", "Write: write_file"),
            ("apply_patch", "Patch: apply_patch"),
            ("mcp__db__query", "MCP: mcp__db__query"),
            ("spawn_agent", "Subagent: spawn_agent"),
            ("cron_create", "Scheduled: cron_create"),
            ("expand_reduction", "Reduced context"),
        ];
        let mut model = TranscriptModel::default();
        let mut sequence = 1u64;
        for (index, (name, _)) in tools.iter().enumerate() {
            model.apply_event(&event(
                sequence,
                "tool_call_completed",
                json!({
                    "type":"tool_call_completed",
                    "id":format!("gallery-{index}"),
                    "name":name,
                    "output":format!("output-{index}"),
                    "is_error":false
                }),
            ));
            sequence += 1;
        }
        model.apply_event(&event(
            sequence,
            "tool_call_completed",
            json!({"type":"tool_call_completed", "id":"failed", "name":"exec_command", "output":"permission denied", "is_error":true}),
        ));
        sequence += 1;
        model.apply_event(&event(
            sequence,
            "request",
            json!({"type":"request", "request":{"kind":"approval", "payload":{"tool":"exec_command"}}}),
        ));
        sequence += 1;
        model.apply_event(&event(
            sequence,
            "scheduled_prompt_started",
            json!({"type":"scheduled_prompt_started", "name":"nightly"}),
        ));

        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(120));
        for (_, title) in tools {
            assert!(rendered.contains(title), "missing semantic cell {title}");
        }
        assert!(rendered.contains("✗ Command: exec_command\n  permission denied"));
        assert!(rendered.contains("◌ Approval requested"));
        assert!(rendered.contains("✓ scheduled prompt started"));
    }

    #[test]
    fn every_transcript_display_field_strips_terminal_controls() {
        let mut model = TranscriptModel::default();
        model.apply_event(&event(
            1,
            "tool_call_completed",
            json!({
                "id":"1",
                "name":"\u{1b}]0;title\u{7}danger\u{1b}[31m-name",
                "output":"safe\u{1b}]8;;https://evil.invalid\u{7}label\u{1b}]8;;\u{7}",
                "is_error":false
            }),
        ));
        let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
        assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
        assert!(rendered.contains("danger-name"), "{rendered}");
        assert!(rendered.contains("safelabel"), "{rendered}");
    }
}