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            return;
65        }
66
67        let block = Block::new()
68            .border_type(terminal_capabilities::get_border_type())
69            .style(self.session.styles.default_style())
70            .border_style(self.session.styles.border_style());
71
72        let inner = block.inner(area);
73        block.render(area, buf);
74
75        if inner.height == 0 || inner.width == 0 {
76            self.session.set_transcript_area(None);
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        apply_transcript_rows(self.session, effective_height);
87
88        let content_width = effective_width;
89        if content_width == 0 {
90            return;
91        }
92        apply_transcript_width(self.session, content_width);
93
94        let viewport_rows = effective_height as usize;
95        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
96        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
97        let total_rows = self.session.total_transcript_rows(content_width) + effective_padding;
98        let (top_offset, _clamped_total_rows) = self
99            .session
100            .prepare_transcript_scroll(total_rows, viewport_rows);
101        let vertical_offset = top_offset.min(self.session.scroll_manager.max_offset());
102        self.session.transcript_view_top = vertical_offset;
103
104        let visible_start = vertical_offset;
105        let scroll_area = inner;
106
107        // Use cached visible lines to avoid rebuilding on every frame
108        let cached_lines = self.session.collect_transcript_window_cached(
109            content_width,
110            visible_start,
111            viewport_rows,
112        );
113
114        // Check if we need to mutate the lines (fill empty space or add overlays)
115        let fill_count = viewport_rows.saturating_sub(cached_lines.len());
116        let needs_mutation = fill_count > 0 || !self.session.queued_inputs.is_empty();
117
118        // Build the lines Vec for Paragraph (which takes ownership)
119        // Note: Clone is unavoidable because the cache holds a reference to the Arc
120        let visible_lines = if needs_mutation {
121            // Need to mutate, so clone and modify
122            let mut lines = cached_lines.to_vec();
123            if fill_count > 0 {
124                let target_len = lines.len() + fill_count;
125                lines.resize_with(target_len, ratatui::text::Line::default);
126            }
127            self.session.overlay_queue_lines(&mut lines, content_width);
128            lines
129        } else {
130            // No mutation needed, just clone for Paragraph
131            cached_lines.to_vec()
132        };
133
134        // Only clear if content actually changed, not on viewport-only scroll
135        // This is a significant optimization: avoids expensive Clear operation on most scrolls
136        if self.session.transcript_content_changed {
137            Clear.render(scroll_area, buf);
138            self.session.transcript_content_changed = false;
139        }
140        let paragraph =
141            Paragraph::new(visible_lines.clone()).style(self.session.styles.default_style());
142        paragraph.render(scroll_area, buf);
143        apply_full_width_line_backgrounds(buf, scroll_area, &visible_lines);
144    }
145}
146
147fn line_background(line: &ratatui::text::Line<'_>) -> Option<Color> {
148    line.spans.iter().find_map(|span| span.style.bg)
149}
150
151fn apply_full_width_line_backgrounds(
152    buf: &mut Buffer,
153    area: Rect,
154    lines: &[ratatui::text::Line<'_>],
155) {
156    if area.width == 0 || area.height == 0 {
157        return;
158    }
159
160    let max_rows = usize::from(area.height).min(lines.len());
161    for (row, line) in lines.iter().take(max_rows).enumerate() {
162        if let Some(bg) = line_background(line) {
163            let row_rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
164            buf.set_style(row_rect, Style::default().bg(bg));
165        }
166    }
167}