supercode_frontend_tui/transcript/
render.rs1use 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::visible_lines;
17use crate::terminal::hyperlinks::HyperlinkLine;
18use crate::terminal::palette::ColorCapabilities;
19
20use super::ansi::strip_terminal_controls;
21use super::diff::looks_like_diff;
22use super::diff::render_diff;
23use super::markdown::render_markdown;
24use super::CellState;
25use super::TranscriptCell;
26use super::TranscriptKind;
27use super::TranscriptModel;
28
29pub struct TranscriptRenderer<'a> {
31 model: &'a TranscriptModel,
32 capabilities: ColorCapabilities,
33}
34
35impl<'a> TranscriptRenderer<'a> {
36 pub fn new(model: &'a TranscriptModel, capabilities: ColorCapabilities) -> Self {
37 Self {
38 model,
39 capabilities,
40 }
41 }
42
43 pub fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
45 let width = usize::from(width.max(1));
46 let mut lines = Vec::new();
47 for (index, cell) in self.model.cells().iter().enumerate() {
48 if index > 0 {
49 lines.push(HyperlinkLine::from(Line::default()));
50 }
51 let cell_lines = self.render_cell(cell, width);
52 lines.extend(annotate_web_urls(cell_lines));
53 }
54 lines
55 }
56
57 pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
60 let lines = self.lines(area.width);
61 render_hyperlink_lines(area, buffer, &lines);
62 }
63
64 pub fn render_tail_buffer(&self, area: Rect, buffer: &mut Buffer) {
68 let lines = self.lines(area.width);
69 let start = lines.len().saturating_sub(usize::from(area.height));
70 render_hyperlink_lines(area, buffer, &lines[start..]);
71 }
72
73 fn render_cell(&self, cell: &TranscriptCell, width: usize) -> Vec<Line<'static>> {
74 let mut lines = vec![self.header(cell)];
75 if cell.body.is_empty() {
76 return lines;
77 }
78
79 let body_width = width.saturating_sub(2).max(1);
80 let sanitized = strip_terminal_controls(&cell.body);
81 let body = if cell.kind == TranscriptKind::Assistant {
82 render_markdown(&sanitized, body_width, self.capabilities)
83 } else if matches!(cell.kind, TranscriptKind::Patch | TranscriptKind::FileWrite)
84 && looks_like_diff(&sanitized)
85 {
86 render_diff(&sanitized, self.capabilities)
87 .into_iter()
88 .flat_map(|line| adaptive_wrap_lines([line], RtOptions::new(body_width)))
89 .collect()
90 } else if folds_output(cell.kind) {
91 fold(
92 render_plain(&sanitized, body_width, body_style(cell, self.capabilities)),
93 self.capabilities,
94 )
95 } else {
96 render_plain(&sanitized, body_width, body_style(cell, self.capabilities))
97 };
98 lines.extend(body.into_iter().map(prefix_body_line));
99 lines
100 }
101
102 fn header(&self, cell: &TranscriptCell) -> Line<'static> {
103 let (icon, state_style) = match cell.state {
104 CellState::Pending => (
105 "◌",
106 Style::default().fg(self.capabilities.best_color((225, 180, 80))),
107 ),
108 CellState::Complete => (
109 "✓",
110 Style::default().fg(self.capabilities.best_color((80, 200, 120))),
111 ),
112 CellState::Failed => (
113 "✗",
114 Style::default().fg(self.capabilities.best_color((235, 100, 100))),
115 ),
116 };
117 let title_style = Style::default()
118 .fg(kind_color(cell.kind, self.capabilities))
119 .add_modifier(Modifier::BOLD);
120 Line::from(vec![
121 Span::styled(icon.to_string(), state_style),
122 Span::raw(" "),
123 Span::styled(bounded_terminal_metadata(&cell.title, 256), title_style),
124 ])
125 }
126}
127
128fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
133 Paragraph::new(visible_lines(lines.to_vec())).render(area, buffer);
134}
135
136impl Widget for TranscriptRenderer<'_> {
137 fn render(self, area: Rect, buffer: &mut Buffer) {
138 self.render_buffer(area, buffer);
139 }
140}
141
142fn render_plain(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
143 let lines = text
144 .split('\n')
145 .map(|line| Line::from(Span::styled(line.to_string(), style)))
146 .collect::<Vec<_>>();
147 adaptive_wrap_lines(lines, RtOptions::new(width))
148}
149
150const FOLDED_OUTPUT_LINES: usize = 6;
153
154fn folds_output(kind: TranscriptKind) -> bool {
155 matches!(
156 kind,
157 TranscriptKind::Shell
158 | TranscriptKind::FileRead
159 | TranscriptKind::FileWrite
160 | TranscriptKind::Patch
161 | TranscriptKind::Mcp
162 | TranscriptKind::Subagent
163 | TranscriptKind::Scheduled
164 | TranscriptKind::Reduction
165 | TranscriptKind::Generic
166 )
167}
168
169fn fold(mut lines: Vec<Line<'static>>, capabilities: ColorCapabilities) -> Vec<Line<'static>> {
170 if lines.len() <= FOLDED_OUTPUT_LINES + 1 {
172 return lines;
173 }
174 let hidden = lines.len() - FOLDED_OUTPUT_LINES;
175 lines.truncate(FOLDED_OUTPUT_LINES);
176 lines.push(Line::from(Span::styled(
177 format!("… +{hidden} lines"),
178 Style::default()
179 .fg(capabilities.best_color((145, 150, 160)))
180 .add_modifier(Modifier::ITALIC),
181 )));
182 lines
183}
184
185fn prefix_body_line(line: Line<'static>) -> Line<'static> {
186 if line.spans.is_empty() || line.width() == 0 {
187 return Line::default();
188 }
189 let mut spans = Vec::with_capacity(line.spans.len() + 1);
190 spans.push(Span::raw(" "));
191 spans.extend(line.spans);
192 Line::from(spans).style(line.style)
193}
194
195fn body_style(cell: &TranscriptCell, capabilities: ColorCapabilities) -> Style {
196 match cell.kind {
197 TranscriptKind::Reasoning | TranscriptKind::Usage => Style::default()
198 .fg(capabilities.best_color((145, 150, 160)))
199 .add_modifier(Modifier::ITALIC),
200 TranscriptKind::Notice => Style::default().fg(capabilities.best_color((225, 180, 80))),
201 TranscriptKind::Generic => Style::default().fg(capabilities.best_color((160, 165, 175))),
202 _ => Style::default(),
203 }
204}
205
206fn kind_color(kind: TranscriptKind, capabilities: ColorCapabilities) -> ratatui::style::Color {
207 let rgb = match kind {
208 TranscriptKind::User => (120, 190, 255),
209 TranscriptKind::Assistant => (175, 140, 245),
210 TranscriptKind::Shell => (100, 200, 190),
211 TranscriptKind::FileRead | TranscriptKind::FileWrite | TranscriptKind::Patch => {
212 (100, 175, 230)
213 }
214 TranscriptKind::Mcp => (205, 145, 235),
215 TranscriptKind::Approval => (235, 175, 80),
216 TranscriptKind::Subagent => (110, 205, 155),
217 TranscriptKind::Scheduled => (90, 190, 210),
218 TranscriptKind::Reduction => (225, 150, 95),
219 TranscriptKind::Reasoning => (155, 155, 175),
220 TranscriptKind::Notice => (225, 180, 80),
221 TranscriptKind::Usage => (140, 145, 155),
222 TranscriptKind::Generic => (160, 165, 175),
223 };
224 capabilities.best_color(rgb)
225}