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