Skip to main content

vtcode_tui/core_tui/session/render/
mod.rs

1#![allow(dead_code)]
2
3use anstyle::Color as AnsiColorEnum;
4use ratatui::{
5    prelude::*,
6    widgets::{Block, Clear, List, ListItem, Paragraph, Wrap},
7};
8use unicode_width::UnicodeWidthStr;
9
10use super::super::style::ratatui_style_from_inline;
11use super::super::types::{InlineMessageKind, InlineTextStyle};
12use super::terminal_capabilities;
13use super::{
14    Session,
15    file_palette::FilePalette,
16    message::MessageLine,
17    modal::{
18        ModalBodyContext, ModalListLayout, ModalRenderStyles, compute_modal_area,
19        render_modal_body, render_wizard_modal_body,
20    },
21    text_utils,
22};
23use crate::config::constants::ui;
24
25mod modal_renderer;
26mod palettes;
27mod spans;
28
29pub use modal_renderer::render_modal;
30pub use palettes::render_file_palette;
31use spans::{accent_style, border_style, default_style, invalidate_scroll_metrics, text_fallback};
32
33pub(super) fn render_message_spans(session: &Session, index: usize) -> Vec<Span<'static>> {
34    spans::render_message_spans(session, index)
35}
36
37pub(super) fn agent_prefix_spans(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
38    spans::agent_prefix_spans(session, line)
39}
40
41pub(super) fn strip_ansi_codes(text: &str) -> std::borrow::Cow<'_, str> {
42    spans::strip_ansi_codes(text)
43}
44
45pub(super) fn render_tool_segments(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
46    spans::render_tool_segments(session, line)
47}
48
49const USER_PREFIX: &str = "";
50
51#[allow(dead_code)]
52pub fn render(session: &mut Session, frame: &mut Frame<'_>) {
53    let size = frame.area();
54    if size.width == 0 || size.height == 0 {
55        return;
56    }
57
58    // Clear entire frame if modal was just closed to remove artifacts
59    if session.needs_full_clear {
60        frame.render_widget(Clear, size);
61        session.needs_full_clear = false;
62    }
63
64    // Pull any newly forwarded log entries before layout calculations
65    session.poll_log_entries();
66
67    let header_lines = session.header_lines();
68    let header_height = session.header_height_from_lines(size.width, &header_lines);
69    if header_height != session.header_rows {
70        session.header_rows = header_height;
71        recalculate_transcript_rows(session);
72    }
73
74    let status_height = if size.width > 0 && palettes::has_input_status(session) {
75        1
76    } else {
77        0
78    };
79    let inner_width = size
80        .width
81        .saturating_sub(ui::INLINE_INPUT_PADDING_HORIZONTAL.saturating_mul(2));
82    let desired_lines = session.desired_input_lines(inner_width);
83    let block_height = Session::input_block_height_for_lines(desired_lines);
84    let input_height = block_height.saturating_add(status_height);
85    session.apply_input_height(input_height);
86
87    let chunks = Layout::vertical([
88        Constraint::Length(header_height),
89        Constraint::Min(1),
90        Constraint::Length(input_height),
91    ])
92    .split(size);
93
94    let (header_area, transcript_area, input_area) = (chunks[0], chunks[1], chunks[2]);
95
96    // Calculate available height for transcript
97    apply_view_rows(session, transcript_area.height);
98
99    // Render components
100    session.render_header(frame, header_area, &header_lines);
101    if session.show_logs {
102        let split = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
103            .split(transcript_area);
104        render_transcript(session, frame, split[0]);
105        render_log_view(session, frame, split[1]);
106    } else {
107        render_transcript(session, frame, transcript_area);
108    }
109    session.render_input(frame, input_area);
110    render_modal(session, frame, size);
111    super::slash::render_slash_palette(session, frame, size);
112    render_file_palette(session, frame, size);
113}
114
115fn render_log_view(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
116    let block = Block::bordered()
117        .title("Logs")
118        .border_type(terminal_capabilities::get_border_type())
119        .style(default_style(session))
120        .border_style(border_style(session));
121    let inner = block.inner(area);
122    frame.render_widget(block, area);
123    if inner.height == 0 || inner.width == 0 {
124        return;
125    }
126
127    let paragraph = Paragraph::new((*session.log_text()).clone()).wrap(Wrap { trim: false });
128    frame.render_widget(paragraph, inner);
129}
130
131fn modal_list_highlight_style(session: &Session) -> Style {
132    session.styles.modal_list_highlight_style()
133}
134
135pub fn apply_view_rows(session: &mut Session, rows: u16) {
136    let resolved = rows.max(2);
137    if session.view_rows != resolved {
138        session.view_rows = resolved;
139        invalidate_scroll_metrics(session);
140    }
141    recalculate_transcript_rows(session);
142    session.enforce_scroll_bounds();
143}
144
145pub fn apply_transcript_rows(session: &mut Session, rows: u16) {
146    let resolved = rows.max(1);
147    if session.transcript_rows != resolved {
148        session.transcript_rows = resolved;
149        invalidate_scroll_metrics(session);
150    }
151}
152
153pub fn apply_transcript_width(session: &mut Session, width: u16) {
154    if session.transcript_width != width {
155        session.transcript_width = width;
156        invalidate_scroll_metrics(session);
157    }
158}
159
160fn render_transcript(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
161    if area.height == 0 || area.width == 0 {
162        return;
163    }
164    let block = Block::new()
165        .border_type(terminal_capabilities::get_border_type())
166        .style(default_style(session))
167        .border_style(border_style(session));
168    let inner = block.inner(area);
169    frame.render_widget(block, area);
170    if inner.height == 0 || inner.width == 0 {
171        return;
172    }
173
174    apply_transcript_rows(session, inner.height);
175
176    let content_width = inner.width;
177    if content_width == 0 {
178        return;
179    }
180    apply_transcript_width(session, content_width);
181
182    let viewport_rows = inner.height as usize;
183    let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
184    let effective_padding = padding.min(viewport_rows.saturating_sub(1));
185
186    // Skip expensive total_rows calculation if only scrolling (no content change)
187    // This optimization saves ~30-50% CPU on viewport-only scrolls
188    let total_rows = if session.transcript_content_changed {
189        session.total_transcript_rows(content_width) + effective_padding
190    } else {
191        // Reuse last known total if content unchanged
192        session
193            .scroll_manager
194            .last_known_total()
195            .unwrap_or_else(|| session.total_transcript_rows(content_width) + effective_padding)
196    };
197    let (top_offset, _clamped_total_rows) =
198        session.prepare_transcript_scroll(total_rows, viewport_rows);
199    let vertical_offset = top_offset.min(session.scroll_manager.max_offset());
200    session.transcript_view_top = vertical_offset;
201
202    let visible_start = vertical_offset;
203    let scroll_area = inner;
204
205    // Use cached visible lines to avoid re-cloning on viewport-only scrolls
206    let cached_lines =
207        session.collect_transcript_window_cached(content_width, visible_start, viewport_rows);
208
209    // Only clone if we need to mutate (fill or overlay)
210    let fill_count = viewport_rows.saturating_sub(cached_lines.len());
211    let visible_lines = if fill_count > 0 || !session.queued_inputs.is_empty() {
212        // Need to mutate, so clone from Arc
213        let mut lines = (*cached_lines).clone();
214        if fill_count > 0 {
215            let target_len = lines.len() + fill_count;
216            lines.resize_with(target_len, Line::default);
217        }
218        session.overlay_queue_lines(&mut lines, content_width);
219        lines
220    } else {
221        // No mutation needed, use Arc directly
222        (*cached_lines).clone()
223    };
224
225    let paragraph = Paragraph::new(visible_lines)
226        .style(default_style(session))
227        .wrap(Wrap { trim: false });
228
229    // Only clear if content actually changed, not on viewport-only scroll
230    // This is a significant optimization: avoids expensive Clear operation on most scrolls
231    // Combined with layout skip above, this reduces render CPU by ~50% during scrolling
232    if session.transcript_content_changed {
233        frame.render_widget(Clear, scroll_area);
234        session.transcript_content_changed = false;
235    }
236    frame.render_widget(paragraph, scroll_area);
237}
238
239fn header_reserved_rows(session: &Session) -> u16 {
240    session.header_rows.max(ui::INLINE_HEADER_HEIGHT)
241}
242
243fn input_reserved_rows(session: &Session) -> u16 {
244    header_reserved_rows(session) + session.input_height
245}
246
247pub fn recalculate_transcript_rows(session: &mut Session) {
248    let reserved = input_reserved_rows(session).saturating_add(2); // account for transcript block borders
249    let available = session.view_rows.saturating_sub(reserved).max(1);
250    apply_transcript_rows(session, available);
251}
252fn wrap_block_lines(
253    session: &Session,
254    first_prefix: &str,
255    continuation_prefix: &str,
256    content: Vec<Span<'static>>,
257    max_width: usize,
258    border_style: Style,
259) -> Vec<Line<'static>> {
260    wrap_block_lines_with_options(
261        session,
262        first_prefix,
263        continuation_prefix,
264        content,
265        max_width,
266        border_style,
267        true,
268    )
269}
270
271fn wrap_block_lines_no_right_border(
272    session: &Session,
273    first_prefix: &str,
274    continuation_prefix: &str,
275    content: Vec<Span<'static>>,
276    max_width: usize,
277    border_style: Style,
278) -> Vec<Line<'static>> {
279    wrap_block_lines_with_options(
280        session,
281        first_prefix,
282        continuation_prefix,
283        content,
284        max_width,
285        border_style,
286        false,
287    )
288}
289
290fn wrap_block_lines_with_options(
291    session: &Session,
292    first_prefix: &str,
293    continuation_prefix: &str,
294    content: Vec<Span<'static>>,
295    max_width: usize,
296    border_style: Style,
297    show_right_border: bool,
298) -> Vec<Line<'static>> {
299    if max_width < 2 {
300        let fallback = if show_right_border {
301            format!("{}││", first_prefix)
302        } else {
303            format!("{}│", first_prefix)
304        };
305        return vec![Line::from(fallback).style(border_style)];
306    }
307
308    let right_border = if show_right_border {
309        ui::INLINE_BLOCK_BODY_RIGHT
310    } else {
311        ""
312    };
313    let first_prefix_width = first_prefix.chars().count();
314    let continuation_prefix_width = continuation_prefix.chars().count();
315    let prefix_width = first_prefix_width.max(continuation_prefix_width);
316    let border_width = right_border.chars().count();
317    let consumed_width = prefix_width.saturating_add(border_width);
318    let content_width = max_width.saturating_sub(consumed_width);
319
320    if max_width == usize::MAX {
321        let mut spans = vec![Span::styled(first_prefix.to_owned(), border_style)];
322        spans.extend(content);
323        if show_right_border {
324            spans.push(Span::styled(right_border.to_owned(), border_style));
325        }
326        return vec![Line::from(spans)];
327    }
328
329    let mut wrapped = wrap_line(session, Line::from(content), content_width);
330    if wrapped.is_empty() {
331        wrapped.push(Line::default());
332    }
333
334    // Add borders to each wrapped line
335    for (idx, line) in wrapped.iter_mut().enumerate() {
336        let line_width = line.spans.iter().map(|s| s.width()).sum::<usize>();
337        let padding = if show_right_border {
338            content_width.saturating_sub(line_width)
339        } else {
340            0
341        };
342
343        let active_prefix = if idx == 0 {
344            first_prefix
345        } else {
346            continuation_prefix
347        };
348        let mut new_spans = vec![Span::styled(active_prefix.to_owned(), border_style)];
349        new_spans.append(&mut line.spans);
350        if padding > 0 {
351            new_spans.push(Span::styled(" ".repeat(padding), Style::default()));
352        }
353        if show_right_border {
354            new_spans.push(Span::styled(right_border.to_owned(), border_style));
355        }
356        line.spans = new_spans;
357    }
358
359    wrapped
360}
361
362fn pty_block_has_content(session: &Session, index: usize) -> bool {
363    if session.lines.is_empty() {
364        return false;
365    }
366
367    let mut start = index;
368    while start > 0 {
369        let Some(previous) = session.lines.get(start - 1) else {
370            break;
371        };
372        if previous.kind != InlineMessageKind::Pty {
373            break;
374        }
375        start -= 1;
376    }
377
378    let mut end = index;
379    while end + 1 < session.lines.len() {
380        let Some(next) = session.lines.get(end + 1) else {
381            break;
382        };
383        if next.kind != InlineMessageKind::Pty {
384            break;
385        }
386        end += 1;
387    }
388
389    if start > end || end >= session.lines.len() {
390        tracing::warn!(
391            "invalid range: start={}, end={}, len={}",
392            start,
393            end,
394            session.lines.len()
395        );
396        return false;
397    }
398
399    for line in &session.lines[start..=end] {
400        if line
401            .segments
402            .iter()
403            .any(|segment| !segment.text.trim().is_empty())
404        {
405            return true;
406        }
407    }
408
409    false
410}
411
412fn reflow_pty_lines(session: &Session, index: usize, width: u16) -> Vec<Line<'static>> {
413    let Some(line) = session.lines.get(index) else {
414        return vec![Line::default()];
415    };
416
417    let max_width = if width == 0 {
418        usize::MAX
419    } else {
420        width as usize
421    };
422
423    if !pty_block_has_content(session, index) {
424        return Vec::new();
425    }
426
427    let mut border_style = ratatui_style_from_inline(
428        &session.styles.tool_border_style(),
429        session.theme.foreground,
430    );
431    border_style = border_style.add_modifier(Modifier::DIM);
432
433    let prev_is_pty = index
434        .checked_sub(1)
435        .and_then(|prev| session.lines.get(prev))
436        .map(|prev| prev.kind == InlineMessageKind::Pty)
437        .unwrap_or(false);
438
439    let is_start = !prev_is_pty;
440
441    let mut lines = Vec::new();
442
443    let mut combined = String::new();
444    for segment in &line.segments {
445        combined.push_str(segment.text.as_str());
446    }
447    if is_start && combined.trim().is_empty() {
448        return Vec::new();
449    }
450
451    // Render body content - strip ANSI codes to ensure plain text output
452    let fallback = text_fallback(session, InlineMessageKind::Pty).or(session.theme.foreground);
453    let mut body_spans = Vec::new();
454    for segment in &line.segments {
455        let stripped_text = strip_ansi_codes(&segment.text);
456        let mut style = ratatui_style_from_inline(&segment.style, fallback);
457        style = style.add_modifier(Modifier::DIM);
458        body_spans.push(Span::styled(stripped_text.into_owned(), style));
459    }
460
461    // Check if this is a thinking spinner line (skip border rendering)
462    let is_thinking_spinner = combined.contains("Thinking...");
463
464    if is_start {
465        // Render body without borders - just indent with spaces for visual separation
466        if is_thinking_spinner {
467            // Render thinking spinner without borders
468            lines.extend(wrap_block_lines_no_right_border(
469                session,
470                "",
471                "",
472                body_spans,
473                max_width,
474                border_style,
475            ));
476        } else {
477            let body_prefix = "  ";
478            let continuation_prefix =
479                text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
480            lines.extend(wrap_block_lines_no_right_border(
481                session,
482                body_prefix,
483                continuation_prefix.as_str(),
484                body_spans,
485                max_width,
486                border_style,
487            ));
488        }
489    } else {
490        let body_prefix = "  ";
491        let continuation_prefix =
492            text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
493        lines.extend(wrap_block_lines_no_right_border(
494            session,
495            body_prefix,
496            continuation_prefix.as_str(),
497            body_spans,
498            max_width,
499            border_style,
500        ));
501    }
502
503    if lines.is_empty() {
504        lines.push(Line::default());
505    }
506
507    lines
508}
509
510fn message_divider_line(session: &Session, width: usize, kind: InlineMessageKind) -> Line<'static> {
511    if width == 0 {
512        return Line::default();
513    }
514
515    let content = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(width);
516    let style = message_divider_style(session, kind);
517    Line::from(content).style(style)
518}
519
520fn message_divider_style(session: &Session, kind: InlineMessageKind) -> Style {
521    session.styles.message_divider_style(kind)
522}
523
524fn wrap_line(_session: &Session, line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
525    text_utils::wrap_line(line, max_width)
526}
527
528fn justify_wrapped_lines(
529    session: &Session,
530    lines: Vec<Line<'static>>,
531    max_width: usize,
532    kind: InlineMessageKind,
533) -> Vec<Line<'static>> {
534    if max_width == 0 {
535        return lines;
536    }
537
538    let total = lines.len();
539    let mut justified = Vec::with_capacity(total);
540    let mut in_fenced_block = false;
541    for (index, line) in lines.into_iter().enumerate() {
542        let is_last = index + 1 == total;
543        let mut next_in_fenced_block = in_fenced_block;
544        let is_fence_line = {
545            let line_text_storage: std::borrow::Cow<'_, str> = if line.spans.len() == 1 {
546                std::borrow::Cow::Borrowed(&*line.spans[0].content)
547            } else {
548                std::borrow::Cow::Owned(
549                    line.spans
550                        .iter()
551                        .map(|span| &*span.content)
552                        .collect::<String>(),
553                )
554            };
555            let line_text: &str = &line_text_storage;
556            let trimmed_start = line_text.trim_start();
557            trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~")
558        };
559        if is_fence_line {
560            next_in_fenced_block = !in_fenced_block;
561        }
562
563        // Extend diff line backgrounds to full width
564        let processed_line = if is_diff_line(session, &line) {
565            pad_diff_line(session, &line, max_width)
566        } else if kind == InlineMessageKind::Agent
567            && !in_fenced_block
568            && !is_fence_line
569            && should_justify_message_line(session, &line, max_width, is_last)
570        {
571            justify_message_line(session, &line, max_width)
572        } else {
573            line
574        };
575
576        justified.push(processed_line);
577        in_fenced_block = next_in_fenced_block;
578    }
579
580    justified
581}
582
583fn should_justify_message_line(
584    _session: &Session,
585    line: &Line<'static>,
586    max_width: usize,
587    is_last: bool,
588) -> bool {
589    if is_last || max_width == 0 {
590        return false;
591    }
592    if line.spans.len() != 1 {
593        return false;
594    }
595    let text: &str = &line.spans[0].content;
596    if text.trim().is_empty() {
597        return false;
598    }
599    if text.starts_with(char::is_whitespace) {
600        return false;
601    }
602    let trimmed = text.trim();
603    if trimmed.starts_with(|ch: char| ['-', '*', '`', '>', '#'].contains(&ch)) {
604        return false;
605    }
606    if trimmed.contains("```") {
607        return false;
608    }
609    let width = UnicodeWidthStr::width(trimmed);
610    if width >= max_width || width < max_width / 2 {
611        return false;
612    }
613
614    justify_plain_text(text, max_width).is_some()
615}
616
617fn justify_message_line(
618    _session: &Session,
619    line: &Line<'static>,
620    max_width: usize,
621) -> Line<'static> {
622    let span = &line.spans[0];
623    if let Some(justified) = justify_plain_text(&span.content, max_width) {
624        Line::from(justified).style(span.style)
625    } else {
626        line.clone()
627    }
628}
629
630fn is_diff_line(_session: &Session, line: &Line<'static>) -> bool {
631    // Detect actual diff lines: must start with +, -, or space (diff markers)
632    // AND have background color styling applied (from git diff coloring)
633    // This avoids false positives from regular text that happens to start with these chars
634    if line.spans.is_empty() {
635        return false;
636    }
637
638    // Check if any span has background color (diff lines from render have colored backgrounds)
639    let has_bg_color = line.spans.iter().any(|span| span.style.bg.is_some());
640    if !has_bg_color {
641        return false;
642    }
643
644    // Must start with a diff marker character in the first span
645    let first_span_char = line.spans[0].content.chars().next();
646    matches!(first_span_char, Some('+') | Some('-') | Some(' '))
647}
648
649fn pad_diff_line(_session: &Session, line: &Line<'static>, max_width: usize) -> Line<'static> {
650    if max_width == 0 || line.spans.is_empty() {
651        return line.clone();
652    }
653
654    // Calculate actual display width using Unicode width rules
655    let line_width: usize = line
656        .spans
657        .iter()
658        .map(|s| {
659            s.content
660                .chars()
661                .map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1))
662                .sum::<usize>()
663        })
664        .sum();
665
666    let padding_needed = max_width.saturating_sub(line_width);
667
668    if padding_needed == 0 {
669        return line.clone();
670    }
671
672    let padding_style = line
673        .spans
674        .iter()
675        .find_map(|span| span.style.bg)
676        .map(|bg| Style::default().bg(bg))
677        .unwrap_or_default();
678
679    let mut new_spans = Vec::with_capacity(line.spans.len() + 1);
680    new_spans.extend(line.spans.iter().cloned());
681    new_spans.push(Span::styled(" ".repeat(padding_needed), padding_style));
682
683    Line::from(new_spans)
684}
685
686fn prepare_transcript_scroll(
687    session: &mut Session,
688    total_rows: usize,
689    viewport_rows: usize,
690) -> (usize, usize) {
691    let viewport = viewport_rows.max(1);
692    let clamped_total = total_rows.max(1);
693    session.scroll_manager.set_total_rows(clamped_total);
694    session.scroll_manager.set_viewport_rows(viewport as u16);
695    let max_offset = session.scroll_manager.max_offset();
696
697    if session.scroll_manager.offset() > max_offset {
698        session.scroll_manager.set_offset(max_offset);
699    }
700
701    let top_offset = max_offset.saturating_sub(session.scroll_manager.offset());
702    (top_offset, clamped_total)
703}
704
705// Delegate to text_utils module
706fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
707    text_utils::justify_plain_text(text, max_width)
708}