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::{
11    Session, render::apply_transcript_rows, render::apply_transcript_width,
12};
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        apply_transcript_rows(self.session, 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        apply_transcript_width(self.session, 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        // Build the lines Vec for Paragraph (which takes ownership)
122        // Note: Clone is unavoidable because the cache holds a reference to the Arc
123        let visible_lines = if needs_mutation {
124            // Need to mutate, so clone and modify
125            let mut lines = cached_lines.to_vec();
126            if fill_count > 0 {
127                let target_len = lines.len() + fill_count;
128                lines.resize_with(target_len, ratatui::text::Line::default);
129            }
130            self.session.overlay_queue_lines(&mut lines, content_width);
131            lines
132        } else {
133            // No mutation needed, just clone for Paragraph
134            cached_lines.to_vec()
135        };
136        let visible_lines = self
137            .session
138            .decorate_visible_transcript_links(visible_lines, scroll_area);
139
140        // Only clear if content actually changed, not on viewport-only scroll
141        // This is a significant optimization: avoids expensive Clear operation on most scrolls
142        if self.session.transcript_content_changed {
143            Clear.render(scroll_area, buf);
144            self.session.transcript_content_changed = false;
145        }
146        let paragraph =
147            Paragraph::new(visible_lines.clone()).style(self.session.styles.default_style());
148        paragraph.render(scroll_area, buf);
149        apply_full_width_line_backgrounds(buf, scroll_area, &visible_lines);
150    }
151}
152
153fn line_background(line: &ratatui::text::Line<'_>) -> Option<Color> {
154    line.spans.iter().find_map(|span| span.style.bg)
155}
156
157fn apply_full_width_line_backgrounds(
158    buf: &mut Buffer,
159    area: Rect,
160    lines: &[ratatui::text::Line<'_>],
161) {
162    if area.width == 0 || area.height == 0 {
163        return;
164    }
165
166    let max_rows = usize::from(area.height).min(lines.len());
167    for (row, line) in lines.iter().take(max_rows).enumerate() {
168        if let Some(bg) = line_background(line) {
169            let row_rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
170            buf.set_style(row_rect, Style::default().bg(bg));
171        }
172    }
173}