supercode-frontend-tui 0.5.84

Attachable terminal frontend primitives for Volter Harness 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::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 if folds_output(cell.kind) {
            fold(
                render_plain(&sanitized, body_width, body_style(cell, self.capabilities)),
                self.capabilities,
            )
        } 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),
        ])
    }
}

/// The text only. An OSC 8 escape written into a cell's symbol counts its
/// destination's characters toward the cell's width in ratatui's diff, which
/// then skips the cells after it: a bare URL drew as its first letter. The
/// terminal finds a bare URL in the text on its own.
fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
    Paragraph::new(visible_lines(lines.to_vec())).render(area, buffer);
}

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))
}

/// Tool output shows its head, as the harness's own pane does; the model keeps
/// every byte of it.
const FOLDED_OUTPUT_LINES: usize = 6;

fn folds_output(kind: TranscriptKind) -> bool {
    matches!(
        kind,
        TranscriptKind::Shell
            | TranscriptKind::FileRead
            | TranscriptKind::FileWrite
            | TranscriptKind::Patch
            | TranscriptKind::Mcp
            | TranscriptKind::Subagent
            | TranscriptKind::Scheduled
            | TranscriptKind::Reduction
            | TranscriptKind::Generic
    )
}

fn fold(mut lines: Vec<Line<'static>>, capabilities: ColorCapabilities) -> Vec<Line<'static>> {
    // Folding one line away saves nothing; show it instead of the count.
    if lines.len() <= FOLDED_OUTPUT_LINES + 1 {
        return lines;
    }
    let hidden = lines.len() - FOLDED_OUTPUT_LINES;
    lines.truncate(FOLDED_OUTPUT_LINES);
    lines.push(Line::from(Span::styled(
        format!("… +{hidden} lines"),
        Style::default()
            .fg(capabilities.best_color((145, 150, 160)))
            .add_modifier(Modifier::ITALIC),
    )));
    lines
}

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)
}