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;
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,
}
}
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
}
pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
let lines = self.lines(area.width);
render_hyperlink_lines(area, buffer, &lines);
}
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}");
}
}