vtcode_tui/core_tui/widgets/
transcript.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Style},
5 text::{Line, Span},
6 widgets::{Block, Clear, Paragraph, Widget},
7};
8
9use crate::config::constants::ui;
10use crate::ui::tui::session::terminal_capabilities;
11use crate::ui::tui::session::{Session, TranscriptLine, pulse_spinner_frame_for_phase};
12use vtcode_config::constants::tools;
13
14pub struct TranscriptWidget<'a> {
30 session: &'a mut Session,
31 show_scrollbar: bool,
32 custom_style: Option<Style>,
33}
34
35impl<'a> TranscriptWidget<'a> {
36 pub fn new(session: &'a mut Session) -> Self {
38 Self {
39 session,
40 show_scrollbar: false,
41 custom_style: None,
42 }
43 }
44
45 #[must_use]
47 pub fn show_scrollbar(mut self, show: bool) -> Self {
48 self.show_scrollbar = show;
49 self
50 }
51
52 #[must_use]
54 pub fn custom_style(mut self, style: Style) -> Self {
55 self.custom_style = Some(style);
56 self
57 }
58}
59
60impl<'a> Widget for TranscriptWidget<'a> {
61 fn render(self, area: Rect, buf: &mut Buffer) {
62 if area.height == 0 || area.width == 0 {
63 self.session.set_transcript_area(None);
64 self.session.clear_transcript_file_link_targets();
65 return;
66 }
67
68 let block = Block::new()
69 .border_type(terminal_capabilities::get_border_type())
70 .style(self.session.styles.default_style())
71 .border_style(self.session.styles.border_style());
72
73 let inner = block.inner(area);
74 block.render(area, buf);
75
76 if inner.height == 0 || inner.width == 0 {
77 self.session.set_transcript_area(None);
78 self.session.clear_transcript_file_link_targets();
79 return;
80 }
81 self.session.set_transcript_area(Some(inner));
82
83 let effective_height = inner.height.min(ui::TUI_MAX_VIEWPORT_HEIGHT);
86 let effective_width = inner.width.min(ui::TUI_MAX_VIEWPORT_WIDTH);
87
88 self.session.apply_transcript_rows(effective_height);
89
90 let content_width = effective_width;
91 if content_width == 0 {
92 self.session.clear_transcript_file_link_targets();
93 return;
94 }
95 self.session.apply_transcript_width(content_width);
96
97 let viewport_rows = effective_height as usize;
98 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
99 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
100 let total_rows = self.session.total_transcript_rows(content_width) + effective_padding;
101 let (top_offset, _clamped_total_rows) = self
102 .session
103 .prepare_transcript_scroll(total_rows, viewport_rows);
104 let vertical_offset = top_offset.min(self.session.scroll_manager.max_offset());
105 self.session.transcript_view_top = vertical_offset;
106
107 let visible_start = vertical_offset;
108 let scroll_area = inner;
109
110 let cached_lines = self.session.collect_transcript_window_cached(
112 content_width,
113 visible_start,
114 viewport_rows,
115 );
116
117 let fill_count = viewport_rows.saturating_sub(cached_lines.len());
119 let needs_mutation = fill_count > 0 || !self.session.queued_inputs.is_empty();
120
121 let mut visible_lines = if needs_mutation {
122 let mut lines = cached_lines.to_vec();
124 if fill_count > 0 {
125 let target_len = lines.len() + fill_count;
126 lines.resize_with(target_len, TranscriptLine::default);
127 }
128 self.session.overlay_queue_lines(&mut lines, content_width);
129 self.session
130 .decorate_visible_cached_transcript_links(lines, scroll_area)
131 } else {
132 self.session
133 .decorate_borrowed_cached_transcript_links(cached_lines.as_slice(), scroll_area)
134 };
135 apply_active_file_operation_spinner(self.session, &mut visible_lines);
136
137 if self.session.transcript_clear_required {
140 Clear.render(scroll_area, buf);
141 self.session.transcript_clear_required = false;
142 }
143 apply_full_width_line_backgrounds(buf, scroll_area, &visible_lines);
144 let paragraph = Paragraph::new(visible_lines).style(self.session.styles.default_style());
145 paragraph.render(scroll_area, buf);
146 }
147}
148
149const FILE_OPERATION_STATUS_TOOLS: &[&str] = &[
150 tools::WRITE_FILE,
151 tools::CREATE_FILE,
152 tools::EDIT_FILE,
153 tools::APPLY_PATCH,
154 tools::SEARCH_REPLACE,
155 tools::DELETE_FILE,
156 tools::UNIFIED_FILE,
157];
158
159const FILE_OPERATION_INDICATORS: &[&str] = &[
160 "❋ Writing ",
161 "❋ Editing ",
162 "❋ Applying patch to ",
163 "❋ Search/replace in ",
164 "❋ Deleting ",
165];
166
167fn apply_active_file_operation_spinner(session: &Session, lines: &mut [Line<'static>]) {
168 let Some(frame) = active_file_operation_spinner_frame(session) else {
169 return;
170 };
171
172 for line in lines.iter_mut().rev() {
173 if is_file_operation_indicator_line(line) && replace_indicator_icon(line, frame) {
174 break;
175 }
176 }
177}
178
179fn active_file_operation_spinner_frame(session: &Session) -> Option<&'static str> {
180 if !session.appearance.should_animate_progress_status() {
181 return None;
182 }
183
184 let left = session.input_status_left.as_deref()?.to_ascii_lowercase();
185 let tool_name = left.strip_prefix("running tool: ")?;
186 let is_active_file_tool = FILE_OPERATION_STATUS_TOOLS.contains(&tool_name);
187
188 is_active_file_tool.then(|| pulse_spinner_frame_for_phase(session.shimmer_state.phase()))
189}
190
191fn is_file_operation_indicator_line(line: &Line<'_>) -> bool {
192 let text = line
193 .spans
194 .iter()
195 .map(|span| span.content.as_ref())
196 .collect::<String>();
197 FILE_OPERATION_INDICATORS
198 .iter()
199 .any(|pattern| text.contains(pattern))
200}
201
202fn replace_indicator_icon(line: &mut Line<'static>, frame: &str) -> bool {
203 let mut replaced = false;
204 let mut new_spans = Vec::with_capacity(line.spans.len() + 2);
205
206 for span in std::mem::take(&mut line.spans) {
207 if replaced {
208 new_spans.push(span);
209 continue;
210 }
211
212 let style = span.style;
213 let text = span.content.into_owned();
214 let Some(icon_index) = text.find('❋') else {
215 new_spans.push(Span::styled(text, style));
216 continue;
217 };
218 let icon_end = icon_index + '❋'.len_utf8();
219 if icon_index > 0 {
220 new_spans.push(Span::styled(text[..icon_index].to_string(), style));
221 }
222 new_spans.push(Span::styled(frame.to_string(), style));
223 if icon_end < text.len() {
224 new_spans.push(Span::styled(text[icon_end..].to_string(), style));
225 }
226 replaced = true;
227 }
228
229 line.spans = new_spans;
230 replaced
231}
232
233fn line_background(line: &Line<'_>) -> Option<Color> {
234 line.spans.iter().find_map(|span| span.style.bg)
235}
236
237fn apply_full_width_line_backgrounds(buf: &mut Buffer, area: Rect, lines: &[Line<'_>]) {
238 if area.width == 0 || area.height == 0 {
239 return;
240 }
241
242 let max_rows = usize::from(area.height).min(lines.len());
243 for (row, line) in lines.iter().take(max_rows).enumerate() {
244 if let Some(bg) = line_background(line) {
245 let row_rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
246 buf.set_style(row_rect, Style::default().bg(bg));
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::core_tui::types::{InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
255 use std::sync::Arc;
256
257 fn segment(text: &str) -> InlineSegment {
258 InlineSegment {
259 text: text.to_string(),
260 style: Arc::new(InlineTextStyle::default()),
261 }
262 }
263
264 fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
265 (area.left()..area.right())
266 .map(|x| buf[(x, row)].symbol())
267 .collect::<String>()
268 }
269
270 #[test]
271 fn scroll_metric_invalidation_does_not_request_transcript_clear() {
272 let mut session = Session::new(InlineTheme::default(), None, 12);
273 session.transcript_clear_required = false;
274
275 session.invalidate_scroll_metrics();
276
277 assert!(!session.transcript_clear_required);
278 }
279
280 #[test]
281 fn render_clears_stale_wrapped_rows_when_requested() {
282 let area = Rect::new(0, 0, 14, 6);
283 let inner = Rect::new(1, 1, 12, 4);
284 let mut buf = Buffer::empty(area);
285 let mut session = Session::new(InlineTheme::default(), None, 12);
286 session.push_line(
287 InlineMessageKind::Agent,
288 vec![segment("this line wraps across several rows")],
289 );
290
291 TranscriptWidget::new(&mut session).render(area, &mut buf);
292
293 let revision = session.next_revision();
294 session.lines[0].segments = vec![segment("short")];
295 session.lines[0].revision = revision;
296 session.mark_line_dirty(0);
297 session.invalidate_transcript_cache();
298 for row in inner.y + 1..inner.bottom() {
299 for x in inner.left()..inner.right() {
300 buf[(x, row)].set_symbol("X");
301 }
302 }
303
304 TranscriptWidget::new(&mut session).render(area, &mut buf);
305
306 assert!(
307 (inner.y + 1..inner.bottom()).all(|row| row_text(&buf, inner, row).trim().is_empty())
308 );
309 }
310
311 #[test]
312 fn render_preserves_queue_overlay_lines() {
313 let area = Rect::new(0, 0, 20, 6);
314 let inner = Rect::new(1, 1, 18, 4);
315 let mut buf = Buffer::empty(area);
316 let mut session = Session::new(InlineTheme::default(), None, 12);
317 session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
318 session.push_queued_input("queued follow-up".to_string());
319
320 TranscriptWidget::new(&mut session).render(area, &mut buf);
321
322 let bottom_row = row_text(&buf, inner, inner.bottom() - 1);
323 assert!(bottom_row.contains("queued"));
324 }
325
326 #[test]
327 fn render_clears_stale_queue_overlay_rows_when_queue_is_removed() {
328 let area = Rect::new(0, 0, 20, 6);
329 let inner = Rect::new(1, 1, 18, 4);
330 let mut buf = Buffer::empty(area);
331 let mut session = Session::new(InlineTheme::default(), None, 12);
332 session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
333 session.push_queued_input("queued follow-up".to_string());
334
335 TranscriptWidget::new(&mut session).render(area, &mut buf);
336 assert!(row_text(&buf, inner, inner.bottom() - 1).contains("queued"));
337
338 let _ = session.pop_latest_queued_input();
339
340 TranscriptWidget::new(&mut session).render(area, &mut buf);
341
342 assert!(row_text(&buf, inner, inner.bottom() - 1).trim().is_empty());
343 }
344
345 #[test]
346 fn resize_larger_keeps_existing_transcript_lines_visible() {
347 let small_area = Rect::new(0, 0, 20, 4);
348 let large_area = Rect::new(0, 0, 20, 10);
349 let small_inner = Rect::new(1, 1, 18, 2);
350 let large_inner = Rect::new(1, 1, 18, 8);
351 let mut small_buf = Buffer::empty(small_area);
352 let mut large_buf = Buffer::empty(large_area);
353 let mut session = Session::new(InlineTheme::default(), None, 12);
354
355 for index in 0..6 {
356 session.push_line(
357 InlineMessageKind::Agent,
358 vec![segment(&format!("line {index}"))],
359 );
360 }
361
362 TranscriptWidget::new(&mut session).render(small_area, &mut small_buf);
363 let small_rendered: Vec<String> = (small_inner.y..small_inner.bottom())
364 .map(|row| row_text(&small_buf, small_inner, row).trim().to_string())
365 .filter(|row| !row.is_empty())
366 .collect();
367 TranscriptWidget::new(&mut session).render(large_area, &mut large_buf);
368
369 let rendered: Vec<String> = (large_inner.y..large_inner.bottom())
370 .map(|row| row_text(&large_buf, large_inner, row).trim().to_string())
371 .filter(|row| !row.is_empty())
372 .collect();
373
374 assert!(rendered.len() > small_rendered.len());
375 assert!(rendered.iter().any(|row| row == "line 1"));
376 assert!(rendered.iter().any(|row| row == "line 5"));
377 }
378
379 #[test]
380 fn width_resize_keeps_transcript_visible() {
381 let wide_area = Rect::new(0, 0, 28, 8);
382 let narrow_area = Rect::new(0, 0, 16, 8);
383 let narrow_inner = Rect::new(1, 1, 14, 6);
384 let mut wide_buf = Buffer::empty(wide_area);
385 let mut narrow_buf = Buffer::empty(narrow_area);
386 let mut session = Session::new(InlineTheme::default(), None, 12);
387
388 for index in 0..4 {
389 session.push_line(
390 InlineMessageKind::Agent,
391 vec![segment(&format!("line {index}"))],
392 );
393 }
394
395 TranscriptWidget::new(&mut session).render(wide_area, &mut wide_buf);
396 TranscriptWidget::new(&mut session).render(narrow_area, &mut narrow_buf);
397
398 let rendered: Vec<String> = (narrow_inner.y..narrow_inner.bottom())
399 .map(|row| row_text(&narrow_buf, narrow_inner, row).trim().to_string())
400 .filter(|row| !row.is_empty())
401 .collect();
402
403 assert!(!rendered.is_empty());
404 assert!(rendered.iter().any(|row| row == "line 1"));
405 assert!(rendered.iter().any(|row| row == "line 3"));
406 }
407}