Skip to main content

vtcode_tui/core_tui/widgets/
transcript.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Style},
5    widgets::{Block, Clear, Paragraph, Widget},
6};
7
8use crate::config::constants::ui;
9use crate::ui::tui::session::terminal_capabilities;
10use crate::ui::tui::session::{Session, TranscriptLine};
11
12/// Widget for rendering the transcript area with conversation history
13///
14/// This widget handles:
15/// - Scroll viewport management
16/// - Content caching and optimization
17/// - Text wrapping and overflow
18/// - Queue overlay rendering
19///
20/// # Example
21/// ```ignore
22/// TranscriptWidget::new(session)
23///     .show_scrollbar(true)
24///     .custom_style(style)
25///     .render(area, buf);
26/// ```
27pub struct TranscriptWidget<'a> {
28    session: &'a mut Session,
29    show_scrollbar: bool,
30    custom_style: Option<Style>,
31}
32
33impl<'a> TranscriptWidget<'a> {
34    /// Create a new TranscriptWidget with required parameters
35    pub fn new(session: &'a mut Session) -> Self {
36        Self {
37            session,
38            show_scrollbar: false,
39            custom_style: None,
40        }
41    }
42
43    /// Enable or disable scrollbar rendering
44    #[must_use]
45    pub fn show_scrollbar(mut self, show: bool) -> Self {
46        self.show_scrollbar = show;
47        self
48    }
49
50    /// Set a custom style for the transcript
51    #[must_use]
52    pub fn custom_style(mut self, style: Style) -> Self {
53        self.custom_style = Some(style);
54        self
55    }
56}
57
58impl<'a> Widget for TranscriptWidget<'a> {
59    fn render(self, area: Rect, buf: &mut Buffer) {
60        if area.height == 0 || area.width == 0 {
61            self.session.set_transcript_area(None);
62            self.session.clear_transcript_file_link_targets();
63            return;
64        }
65
66        let block = Block::new()
67            .border_type(terminal_capabilities::get_border_type())
68            .style(self.session.styles.default_style())
69            .border_style(self.session.styles.border_style());
70
71        let inner = block.inner(area);
72        block.render(area, buf);
73
74        if inner.height == 0 || inner.width == 0 {
75            self.session.set_transcript_area(None);
76            self.session.clear_transcript_file_link_targets();
77            return;
78        }
79        self.session.set_transcript_area(Some(inner));
80
81        // Clamp effective dimensions to prevent pathological CPU usage with huge terminals
82        // See: https://github.com/anthropics/claude-code/issues/21567
83        let effective_height = inner.height.min(ui::TUI_MAX_VIEWPORT_HEIGHT);
84        let effective_width = inner.width.min(ui::TUI_MAX_VIEWPORT_WIDTH);
85
86        self.session.apply_transcript_rows(effective_height);
87
88        let content_width = effective_width;
89        if content_width == 0 {
90            self.session.clear_transcript_file_link_targets();
91            return;
92        }
93        self.session.apply_transcript_width(content_width);
94
95        let viewport_rows = effective_height as usize;
96        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
97        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
98        let total_rows = self.session.total_transcript_rows(content_width) + effective_padding;
99        let (top_offset, _clamped_total_rows) = self
100            .session
101            .prepare_transcript_scroll(total_rows, viewport_rows);
102        let vertical_offset = top_offset.min(self.session.scroll_manager.max_offset());
103        self.session.transcript_view_top = vertical_offset;
104
105        let visible_start = vertical_offset;
106        let scroll_area = inner;
107
108        // Use cached visible lines to avoid rebuilding on every frame
109        let cached_lines = self.session.collect_transcript_window_cached(
110            content_width,
111            visible_start,
112            viewport_rows,
113        );
114
115        // Check if we need to mutate the lines (fill empty space or add overlays)
116        let fill_count = viewport_rows.saturating_sub(cached_lines.len());
117        let needs_mutation = fill_count > 0 || !self.session.queued_inputs.is_empty();
118
119        // Build the lines Vec for Paragraph (which takes ownership)
120        // Note: Clone is unavoidable because the cache holds a reference to the Arc
121        let 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            lines
130        } else {
131            // No mutation needed, just clone for Paragraph
132            cached_lines.to_vec()
133        };
134        let visible_lines = self
135            .session
136            .decorate_visible_transcript_links(visible_lines, scroll_area);
137
138        // Only clear if content actually changed, not on viewport-only scroll
139        // This is a significant optimization: avoids expensive Clear operation on most scrolls
140        if self.session.transcript_content_changed {
141            Clear.render(scroll_area, buf);
142            self.session.transcript_content_changed = false;
143        }
144        let paragraph =
145            Paragraph::new(visible_lines.clone()).style(self.session.styles.default_style());
146        paragraph.render(scroll_area, buf);
147        apply_full_width_line_backgrounds(buf, scroll_area, &visible_lines);
148    }
149}
150
151fn line_background(line: &ratatui::text::Line<'_>) -> Option<Color> {
152    line.spans.iter().find_map(|span| span.style.bg)
153}
154
155fn apply_full_width_line_backgrounds(
156    buf: &mut Buffer,
157    area: Rect,
158    lines: &[ratatui::text::Line<'_>],
159) {
160    if area.width == 0 || area.height == 0 {
161        return;
162    }
163
164    let max_rows = usize::from(area.height).min(lines.len());
165    for (row, line) in lines.iter().take(max_rows).enumerate() {
166        if let Some(bg) = line_background(line) {
167            let row_rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
168            buf.set_style(row_rect, Style::default().bg(bg));
169        }
170    }
171}