1use 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::mark_buffer_hyperlinks;
17use crate::terminal::hyperlinks::visible_lines;
18use crate::terminal::hyperlinks::HyperlinkLine;
19use crate::terminal::palette::ColorCapabilities;
20
21use super::ansi::strip_terminal_controls;
22use super::diff::looks_like_diff;
23use super::diff::render_diff;
24use super::markdown::render_markdown;
25use super::CellState;
26use super::TranscriptCell;
27use super::TranscriptKind;
28use super::TranscriptModel;
29
30pub struct TranscriptRenderer<'a> {
32 model: &'a TranscriptModel,
33 capabilities: ColorCapabilities,
34}
35
36impl<'a> TranscriptRenderer<'a> {
37 pub fn new(model: &'a TranscriptModel, capabilities: ColorCapabilities) -> Self {
38 Self {
39 model,
40 capabilities,
41 }
42 }
43
44 pub fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
46 let width = usize::from(width.max(1));
47 let mut lines = Vec::new();
48 for (index, cell) in self.model.cells().iter().enumerate() {
49 if index > 0 {
50 lines.push(HyperlinkLine::from(Line::default()));
51 }
52 let cell_lines = self.render_cell(cell, width);
53 lines.extend(annotate_web_urls(cell_lines));
54 }
55 lines
56 }
57
58 pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
61 let lines = self.lines(area.width);
62 render_hyperlink_lines(area, buffer, &lines);
63 }
64
65 pub fn render_tail_buffer(&self, area: Rect, buffer: &mut Buffer) {
69 let lines = self.lines(area.width);
70 let start = lines.len().saturating_sub(usize::from(area.height));
71 render_hyperlink_lines(area, buffer, &lines[start..]);
72 }
73
74 fn render_cell(&self, cell: &TranscriptCell, width: usize) -> Vec<Line<'static>> {
75 let mut lines = vec![self.header(cell)];
76 if cell.body.is_empty() {
77 return lines;
78 }
79
80 let body_width = width.saturating_sub(2).max(1);
81 let sanitized = strip_terminal_controls(&cell.body);
82 let body = if cell.kind == TranscriptKind::Assistant {
83 render_markdown(&sanitized, body_width, self.capabilities)
84 } else if matches!(cell.kind, TranscriptKind::Patch | TranscriptKind::FileWrite)
85 && looks_like_diff(&sanitized)
86 {
87 render_diff(&sanitized, self.capabilities)
88 .into_iter()
89 .flat_map(|line| adaptive_wrap_lines([line], RtOptions::new(body_width)))
90 .collect()
91 } else {
92 render_plain(&sanitized, body_width, body_style(cell, self.capabilities))
93 };
94 lines.extend(body.into_iter().map(prefix_body_line));
95 lines
96 }
97
98 fn header(&self, cell: &TranscriptCell) -> Line<'static> {
99 let (icon, state_style) = match cell.state {
100 CellState::Pending => (
101 "◌",
102 Style::default().fg(self.capabilities.best_color((225, 180, 80))),
103 ),
104 CellState::Complete => (
105 "✓",
106 Style::default().fg(self.capabilities.best_color((80, 200, 120))),
107 ),
108 CellState::Failed => (
109 "✗",
110 Style::default().fg(self.capabilities.best_color((235, 100, 100))),
111 ),
112 };
113 let title_style = Style::default()
114 .fg(kind_color(cell.kind, self.capabilities))
115 .add_modifier(Modifier::BOLD);
116 Line::from(vec![
117 Span::styled(icon.to_string(), state_style),
118 Span::raw(" "),
119 Span::styled(bounded_terminal_metadata(&cell.title, 256), title_style),
120 ])
121 }
122}
123
124fn render_hyperlink_lines(area: Rect, buffer: &mut Buffer, lines: &[HyperlinkLine]) {
125 let lines = lines.to_vec();
126 Paragraph::new(visible_lines(lines.clone())).render(area, buffer);
127 mark_buffer_hyperlinks(buffer, area, &lines, 0);
128}
129
130impl Widget for TranscriptRenderer<'_> {
131 fn render(self, area: Rect, buffer: &mut Buffer) {
132 self.render_buffer(area, buffer);
133 }
134}
135
136fn render_plain(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
137 let lines = text
138 .split('\n')
139 .map(|line| Line::from(Span::styled(line.to_string(), style)))
140 .collect::<Vec<_>>();
141 adaptive_wrap_lines(lines, RtOptions::new(width))
142}
143
144fn prefix_body_line(line: Line<'static>) -> Line<'static> {
145 if line.spans.is_empty() || line.width() == 0 {
146 return Line::default();
147 }
148 let mut spans = Vec::with_capacity(line.spans.len() + 1);
149 spans.push(Span::raw(" "));
150 spans.extend(line.spans);
151 Line::from(spans).style(line.style)
152}
153
154fn body_style(cell: &TranscriptCell, capabilities: ColorCapabilities) -> Style {
155 match cell.kind {
156 TranscriptKind::Reasoning | TranscriptKind::Usage => Style::default()
157 .fg(capabilities.best_color((145, 150, 160)))
158 .add_modifier(Modifier::ITALIC),
159 TranscriptKind::Notice => Style::default().fg(capabilities.best_color((225, 180, 80))),
160 TranscriptKind::Generic => Style::default().fg(capabilities.best_color((160, 165, 175))),
161 _ => Style::default(),
162 }
163}
164
165fn kind_color(kind: TranscriptKind, capabilities: ColorCapabilities) -> ratatui::style::Color {
166 let rgb = match kind {
167 TranscriptKind::User => (120, 190, 255),
168 TranscriptKind::Assistant => (175, 140, 245),
169 TranscriptKind::Shell => (100, 200, 190),
170 TranscriptKind::FileRead | TranscriptKind::FileWrite | TranscriptKind::Patch => {
171 (100, 175, 230)
172 }
173 TranscriptKind::Mcp => (205, 145, 235),
174 TranscriptKind::Approval => (235, 175, 80),
175 TranscriptKind::Subagent => (110, 205, 155),
176 TranscriptKind::Scheduled => (90, 190, 210),
177 TranscriptKind::Reduction => (225, 150, 95),
178 TranscriptKind::Reasoning => (155, 155, 175),
179 TranscriptKind::Notice => (225, 180, 80),
180 TranscriptKind::Usage => (140, 145, 155),
181 TranscriptKind::Generic => (160, 165, 175),
182 };
183 capabilities.best_color(rgb)
184}
185
186#[cfg(test)]
187mod tests {
188 use ratatui::style::Color;
189 use serde_json::json;
190 use supercode::frontend::FrontendEvent;
191
192 use crate::terminal::palette::ColorLevel;
193
194 use super::*;
195
196 fn capabilities() -> ColorCapabilities {
197 ColorCapabilities {
198 level: ColorLevel::TrueColor,
199 color_enabled: true,
200 }
201 }
202
203 fn event(sequence: u64, kind: &str, payload: serde_json::Value) -> FrontendEvent {
204 FrontendEvent {
205 sequence,
206 kind: kind.into(),
207 payload,
208 }
209 }
210
211 fn visible(lines: &[HyperlinkLine]) -> String {
212 lines
213 .iter()
214 .map(|line| {
215 line.line
216 .spans
217 .iter()
218 .map(|span| span.content.as_ref())
219 .collect::<String>()
220 })
221 .collect::<Vec<_>>()
222 .join("\n")
223 }
224
225 #[test]
226 fn ansi_tool_output_is_sanitized_and_semantic_header_remains() {
227 let mut model = TranscriptModel::default();
228 model.apply_event(&event(
229 1,
230 "tool_call_completed",
231 json!({"type":"tool_call_completed", "id":"1", "name":"exec_command", "output":"\u{1b}[31mfailed\u{1b}[0m", "is_error":true}),
232 ));
233 let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
234 assert_eq!(rendered, "✗ Command: exec_command\n failed");
235 assert!(!rendered.contains('\u{1b}'));
236 }
237
238 #[test]
239 fn resize_reflows_without_changing_content_or_cell_identity() {
240 let mut model = TranscriptModel::default();
241 model.apply_event(&event(
242 1,
243 "text_delta",
244 json!({"type":"text_delta", "text":"A long assistant sentence reflows when the terminal becomes narrow."}),
245 ));
246 let renderer = TranscriptRenderer::new(&model, capabilities());
247 let narrow = visible(&renderer.lines(28));
248 let wide = visible(&renderer.lines(80));
249 assert!(narrow.lines().count() > wide.lines().count());
250 assert_eq!(model.cells().len(), 1);
251 assert_eq!(model.cells()[0].state, CellState::Pending);
252 assert!(narrow.contains("A long assistant sentence"));
253 assert!(wide.contains("A long assistant sentence reflows"));
254 }
255
256 #[test]
257 fn semantic_hyperlink_marks_buffer_without_affecting_visible_geometry() {
258 let mut model = TranscriptModel::default();
259 model.apply_event(&event(
260 1,
261 "text_delta",
262 json!({"type":"text_delta", "text":"See https://example.com/path"}),
263 ));
264 let renderer = TranscriptRenderer::new(&model, capabilities());
265 let area = Rect::new(0, 0, 40, 3);
266 let mut buffer = Buffer::empty(area);
267 renderer.render_buffer(area, &mut buffer);
268 assert_eq!(buffer[(0, 0)].symbol(), "◌");
269 assert!(buffer[(6, 1)]
270 .symbol()
271 .contains("\x1b]8;;https://example.com/path"));
272 assert_eq!(buffer[(6, 1)].fg, Color::Reset);
273 }
274
275 #[test]
276 fn normalized_coding_agent_event_gallery_has_semantic_cells() {
277 let tools = [
278 ("exec_command", "Command: exec_command"),
279 ("read_file", "Read: read_file"),
280 ("write_file", "Write: write_file"),
281 ("apply_patch", "Patch: apply_patch"),
282 ("mcp__db__query", "MCP: mcp__db__query"),
283 ("spawn_agent", "Subagent: spawn_agent"),
284 ("cron_create", "Scheduled: cron_create"),
285 ("expand_reduction", "Reduced context"),
286 ];
287 let mut model = TranscriptModel::default();
288 let mut sequence = 1u64;
289 for (index, (name, _)) in tools.iter().enumerate() {
290 model.apply_event(&event(
291 sequence,
292 "tool_call_completed",
293 json!({
294 "type":"tool_call_completed",
295 "id":format!("gallery-{index}"),
296 "name":name,
297 "output":format!("output-{index}"),
298 "is_error":false
299 }),
300 ));
301 sequence += 1;
302 }
303 model.apply_event(&event(
304 sequence,
305 "tool_call_completed",
306 json!({"type":"tool_call_completed", "id":"failed", "name":"exec_command", "output":"permission denied", "is_error":true}),
307 ));
308 sequence += 1;
309 model.apply_event(&event(
310 sequence,
311 "request",
312 json!({"type":"request", "request":{"kind":"approval", "payload":{"tool":"exec_command"}}}),
313 ));
314 sequence += 1;
315 model.apply_event(&event(
316 sequence,
317 "scheduled_prompt_started",
318 json!({"type":"scheduled_prompt_started", "name":"nightly"}),
319 ));
320
321 let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(120));
322 for (_, title) in tools {
323 assert!(rendered.contains(title), "missing semantic cell {title}");
324 }
325 assert!(rendered.contains("✗ Command: exec_command\n permission denied"));
326 assert!(rendered.contains("◌ Approval requested"));
327 assert!(rendered.contains("✓ scheduled prompt started"));
328 }
329
330 #[test]
331 fn every_transcript_display_field_strips_terminal_controls() {
332 let mut model = TranscriptModel::default();
333 model.apply_event(&event(
334 1,
335 "tool_call_completed",
336 json!({
337 "id":"1",
338 "name":"\u{1b}]0;title\u{7}danger\u{1b}[31m-name",
339 "output":"safe\u{1b}]8;;https://evil.invalid\u{7}label\u{1b}]8;;\u{7}",
340 "is_error":false
341 }),
342 ));
343 let rendered = visible(&TranscriptRenderer::new(&model, capabilities()).lines(80));
344 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
345 assert!(rendered.contains("danger-name"), "{rendered}");
346 assert!(rendered.contains("safelabel"), "{rendered}");
347 }
348}