Skip to main content

vtcode_ui/tui/core_tui/widgets/
transcript.rs

1use 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
14/// Widget for rendering the transcript area with conversation history
15///
16/// This widget handles:
17/// - Scroll viewport management
18/// - Content caching and optimization
19/// - Text wrapping and overflow
20/// - Queue overlay rendering
21///
22/// # Example
23/// ```ignore
24/// TranscriptWidget::new(session)
25///     .show_scrollbar(true)
26///     .custom_style(style)
27///     .render(area, buf);
28/// ```
29pub struct TranscriptWidget<'a> {
30    session: &'a mut Session,
31    show_scrollbar: bool,
32    custom_style: Option<Style>,
33}
34
35impl<'a> TranscriptWidget<'a> {
36    /// Create a new TranscriptWidget with required parameters
37    pub fn new(session: &'a mut Session) -> Self {
38        Self {
39            session,
40            show_scrollbar: false,
41            custom_style: None,
42        }
43    }
44
45    /// Enable or disable scrollbar rendering
46    #[must_use]
47    pub fn show_scrollbar(mut self, show: bool) -> Self {
48        self.show_scrollbar = show;
49        self
50    }
51
52    /// Set a custom style for the transcript
53    #[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        // Clamp effective dimensions to prevent pathological CPU usage with huge terminals
84        // See: https://github.com/anthropics/claude-code/issues/21567
85        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        // Use cached visible lines to avoid rebuilding on every frame
111        let cached_lines = self.session.collect_transcript_window_cached(
112            content_width,
113            visible_start,
114            viewport_rows,
115        );
116
117        // Check if we need to mutate the lines (fill empty space or add overlays)
118        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            // Need to mutate, so clone and modify
123            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        // Only clear if content actually changed, not on viewport-only scroll
138        // This is a significant optimization: avoids expensive Clear operation on most scrolls
139        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}