vtcode_ui/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::tui::config::constants::ui;
10use crate::tui::ui::tui::session::terminal_capabilities;
11use crate::tui::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::tui::core_tui::types::{
255 InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme,
256 };
257 use std::sync::Arc;
258
259 fn segment(text: &str) -> InlineSegment {
260 InlineSegment {
261 text: text.to_string(),
262 style: Arc::new(InlineTextStyle::default()),
263 }
264 }
265
266 fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
267 (area.left()..area.right())
268 .map(|x| buf[(x, row)].symbol())
269 .collect::<String>()
270 }
271
272 #[test]
273 fn scroll_metric_invalidation_does_not_request_transcript_clear() {
274 let mut session = Session::new(InlineTheme::default(), None, 12);
275 session.transcript_clear_required = false;
276
277 session.invalidate_scroll_metrics();
278
279 assert!(!session.transcript_clear_required);
280 }
281
282 #[test]
283 fn render_clears_stale_wrapped_rows_when_requested() {
284 let area = Rect::new(0, 0, 14, 6);
285 let inner = Rect::new(1, 1, 12, 4);
286 let mut buf = Buffer::empty(area);
287 let mut session = Session::new(InlineTheme::default(), None, 12);
288 session.push_line(
289 InlineMessageKind::Agent,
290 vec![segment("this line wraps across several rows")],
291 );
292
293 TranscriptWidget::new(&mut session).render(area, &mut buf);
294
295 let revision = session.next_revision();
296 session.lines[0].segments = vec![segment("short")];
297 session.lines[0].revision = revision;
298 session.mark_line_dirty(0);
299 session.invalidate_transcript_cache();
300 for row in inner.y + 1..inner.bottom() {
301 for x in inner.left()..inner.right() {
302 buf[(x, row)].set_symbol("X");
303 }
304 }
305
306 TranscriptWidget::new(&mut session).render(area, &mut buf);
307
308 assert!(
309 (inner.y + 1..inner.bottom()).all(|row| row_text(&buf, inner, row).trim().is_empty())
310 );
311 }
312
313 #[test]
314 fn render_preserves_queue_overlay_lines() {
315 let area = Rect::new(0, 0, 20, 6);
316 let inner = Rect::new(1, 1, 18, 4);
317 let mut buf = Buffer::empty(area);
318 let mut session = Session::new(InlineTheme::default(), None, 12);
319 session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
320 session.push_queued_input("queued follow-up".to_string());
321
322 TranscriptWidget::new(&mut session).render(area, &mut buf);
323
324 let bottom_row = row_text(&buf, inner, inner.bottom() - 1);
325 assert!(bottom_row.contains("queued"));
326 }
327
328 #[test]
329 fn render_clears_stale_queue_overlay_rows_when_queue_is_removed() {
330 let area = Rect::new(0, 0, 20, 6);
331 let inner = Rect::new(1, 1, 18, 4);
332 let mut buf = Buffer::empty(area);
333 let mut session = Session::new(InlineTheme::default(), None, 12);
334 session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
335 session.push_queued_input("queued follow-up".to_string());
336
337 TranscriptWidget::new(&mut session).render(area, &mut buf);
338 assert!(row_text(&buf, inner, inner.bottom() - 1).contains("queued"));
339
340 let _ = session.pop_latest_queued_input();
341
342 TranscriptWidget::new(&mut session).render(area, &mut buf);
343
344 assert!(row_text(&buf, inner, inner.bottom() - 1).trim().is_empty());
345 }
346
347 #[test]
348 fn resize_larger_keeps_existing_transcript_lines_visible() {
349 let small_area = Rect::new(0, 0, 20, 4);
350 let large_area = Rect::new(0, 0, 20, 10);
351 let small_inner = Rect::new(1, 1, 18, 2);
352 let large_inner = Rect::new(1, 1, 18, 8);
353 let mut small_buf = Buffer::empty(small_area);
354 let mut large_buf = Buffer::empty(large_area);
355 let mut session = Session::new(InlineTheme::default(), None, 12);
356
357 for index in 0..6 {
358 session.push_line(
359 InlineMessageKind::Agent,
360 vec![segment(&format!("line {index}"))],
361 );
362 }
363
364 TranscriptWidget::new(&mut session).render(small_area, &mut small_buf);
365 let small_rendered: Vec<String> = (small_inner.y..small_inner.bottom())
366 .map(|row| row_text(&small_buf, small_inner, row).trim().to_string())
367 .filter(|row| !row.is_empty())
368 .collect();
369 TranscriptWidget::new(&mut session).render(large_area, &mut large_buf);
370
371 let rendered: Vec<String> = (large_inner.y..large_inner.bottom())
372 .map(|row| row_text(&large_buf, large_inner, row).trim().to_string())
373 .filter(|row| !row.is_empty())
374 .collect();
375
376 assert!(rendered.len() > small_rendered.len());
377 assert!(rendered.iter().any(|row| row == "line 1"));
378 assert!(rendered.iter().any(|row| row == "line 5"));
379 }
380
381 #[test]
382 fn width_resize_keeps_transcript_visible() {
383 let wide_area = Rect::new(0, 0, 28, 8);
384 let narrow_area = Rect::new(0, 0, 16, 8);
385 let narrow_inner = Rect::new(1, 1, 14, 6);
386 let mut wide_buf = Buffer::empty(wide_area);
387 let mut narrow_buf = Buffer::empty(narrow_area);
388 let mut session = Session::new(InlineTheme::default(), None, 12);
389
390 for index in 0..4 {
391 session.push_line(
392 InlineMessageKind::Agent,
393 vec![segment(&format!("line {index}"))],
394 );
395 }
396
397 TranscriptWidget::new(&mut session).render(wide_area, &mut wide_buf);
398 TranscriptWidget::new(&mut session).render(narrow_area, &mut narrow_buf);
399
400 let rendered: Vec<String> = (narrow_inner.y..narrow_inner.bottom())
401 .map(|row| row_text(&narrow_buf, narrow_inner, row).trim().to_string())
402 .filter(|row| !row.is_empty())
403 .collect();
404
405 assert!(!rendered.is_empty());
406 assert!(rendered.iter().any(|row| row == "line 1"));
407 assert!(rendered.iter().any(|row| row == "line 3"));
408 }
409}