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