Skip to main content

vtcode_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::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
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::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}