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;
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 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),
])
}
}
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))
}
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>> {
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)
}