Skip to main content

vtcode_tui/core_tui/session/render/
modal_renderer.rs

1use super::*;
2use crate::config::constants::ui;
3use crate::core_tui::session::list_panel::input_styles_from_theme;
4use crate::core_tui::session::transcript_links::decorate_detected_link_lines;
5use crate::core_tui::style::ratatui_color_from_ansi;
6use crate::core_tui::types::InlineMessageKind;
7use crate::ui::tui::session::modal::{
8    ModalBodyContext, ModalListState, ModalRenderStyles, render_modal_body,
9    render_wizard_modal_body,
10};
11use crate::ui::tui::types::InlineListSelection;
12use anstyle::{Ansi256Color, Color as AnsiColorEnum};
13use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
14
15const MAX_INLINE_MODAL_HEIGHT: u16 = 20;
16const MAX_INLINE_MODAL_HEIGHT_MULTILINE: u16 = 32;
17const MAX_INLINE_INSTRUCTION_ROWS: usize = 6;
18const MODAL_TITLE_CHROME_ROWS: usize = 2;
19
20fn modal_base_style(session: &Session) -> Style {
21    session.styles.default_style()
22}
23
24fn modal_heading_style(session: &Session) -> Style {
25    modal_base_style(session)
26        .fg(ratatui_color_from_ansi(resolve_modal_chrome_ansi_color(
27            session,
28        )))
29        .add_modifier(Modifier::BOLD)
30}
31
32fn list_has_two_line_items(list: &ModalListState) -> bool {
33    list.visible_indices.iter().any(|&index| {
34        list.items.get(index).is_some_and(|item| {
35            item.subtitle
36                .as_ref()
37                .is_some_and(|subtitle| !subtitle.trim().is_empty())
38        })
39    })
40}
41
42fn list_row_cap(list: &ModalListState) -> usize {
43    if list_has_two_line_items(list) {
44        ui::INLINE_LIST_MAX_ROWS_MULTILINE
45    } else {
46        ui::INLINE_LIST_MAX_ROWS
47    }
48}
49
50fn list_desired_rows(list: &ModalListState) -> usize {
51    list.visible_indices.len().clamp(1, list_row_cap(list))
52}
53
54fn modal_title_text(session: &Session) -> &str {
55    session
56        .wizard_overlay()
57        .map(|wizard| wizard.title.as_str())
58        .or_else(|| session.modal_state().map(|modal| modal.title.as_str()))
59        .unwrap_or("")
60}
61
62fn modal_has_title(session: &Session) -> bool {
63    !modal_title_text(session).trim().is_empty()
64}
65
66fn resolve_modal_chrome_ansi_color(session: &Session) -> AnsiColorEnum {
67    session
68        .theme
69        .tool_accent
70        .or(session.theme.primary)
71        .or(session.theme.secondary)
72        .unwrap_or(AnsiColorEnum::Ansi256(Ansi256Color(
73            ui::SAFE_ANSI_BRIGHT_CYAN,
74        )))
75}
76
77fn modal_chrome_style(session: &Session) -> Style {
78    modal_heading_style(session)
79}
80
81fn render_modal_background(frame: &mut Frame<'_>, area: Rect, style: Style) {
82    if area.width == 0 || area.height == 0 {
83        return;
84    }
85
86    frame.render_widget(Clear, area);
87    frame.render_widget(Block::default().style(style), area);
88}
89
90fn render_modal_divider(frame: &mut Frame<'_>, area: Rect, style: Style) {
91    if area.width == 0 || area.height == 0 {
92        return;
93    }
94
95    frame.render_widget(
96        Paragraph::new(Line::from(Span::styled(
97            ui::INLINE_BLOCK_HORIZONTAL.repeat(area.width as usize),
98            style,
99        )))
100        .wrap(Wrap { trim: false }),
101        area,
102    );
103}
104
105fn wizard_step_has_inline_custom_editor(
106    wizard: &crate::ui::tui::session::modal::WizardModalState,
107) -> bool {
108    let Some(step) = wizard.steps.get(wizard.current_step) else {
109        return false;
110    };
111    let Some(selected_visible) = step.list.list_state.selected() else {
112        return false;
113    };
114    let Some(&item_index) = step.list.visible_indices.get(selected_visible) else {
115        return false;
116    };
117    let Some(item) = step.list.items.get(item_index) else {
118        return false;
119    };
120    matches!(
121        item.selection.as_ref(),
122        Some(InlineListSelection::RequestUserInputAnswer {
123            selected,
124            other,
125            ..
126        }) if selected.is_empty() && other.is_some()
127    )
128}
129
130pub fn split_inline_modal_area(session: &Session, area: Rect) -> (Rect, Option<Rect>) {
131    if area.width == 0 || area.height == 0 {
132        return (area, None);
133    }
134
135    let title_chrome_rows = if modal_has_title(session) {
136        MODAL_TITLE_CHROME_ROWS as u16
137    } else {
138        0
139    };
140    let multiline_list_present = if let Some(wizard) = session.wizard_overlay() {
141        wizard
142            .steps
143            .get(wizard.current_step)
144            .is_some_and(|step| list_has_two_line_items(&step.list))
145    } else if let Some(modal) = session.modal_state() {
146        modal.list.as_ref().is_some_and(list_has_two_line_items)
147    } else {
148        false
149    };
150
151    let desired_lines = if let Some(wizard) = session.wizard_overlay() {
152        let mut lines = 0usize;
153        lines = lines.saturating_add(1); // tabs/header
154        if wizard.search.is_some() {
155            lines = lines.saturating_add(1);
156        }
157        lines = lines.saturating_add(2); // question and spacing
158        if wizard.search.is_some() {
159            lines = lines.saturating_add(1); // divider before list
160        }
161        let (list_rows, summary_rows) = wizard
162            .steps
163            .get(wizard.current_step)
164            .map(|step| {
165                (
166                    list_desired_rows(&step.list),
167                    step.list.summary_line_rows(None),
168                )
169            })
170            .unwrap_or((1, 0));
171        lines = lines.saturating_add(list_rows);
172        lines = lines.saturating_add(summary_rows);
173        if wizard
174            .steps
175            .get(wizard.current_step)
176            .is_some_and(|step| step.notes_active || !step.notes.is_empty())
177            && !wizard_step_has_inline_custom_editor(wizard)
178        {
179            lines = lines.saturating_add(1);
180        }
181        lines = lines.saturating_add(
182            wizard
183                .instruction_lines()
184                .len()
185                .min(MAX_INLINE_INSTRUCTION_ROWS),
186        );
187        if title_chrome_rows > 0 {
188            lines = lines.saturating_add(1 + usize::from(title_chrome_rows)); // title row + dividers
189        }
190        lines
191    } else if let Some(modal) = session.modal_state() {
192        let mut lines = modal.lines.len().clamp(1, MAX_INLINE_INSTRUCTION_ROWS);
193        if modal.search.is_some() {
194            lines = lines.saturating_add(2);
195        }
196        if modal.secure_prompt.is_some() {
197            lines = lines.saturating_add(2);
198        }
199        if modal.list.is_some() && modal.search.is_some() {
200            lines = lines.saturating_add(1); // divider before list
201        }
202        if let Some(list) = modal.list.as_ref() {
203            lines = lines.saturating_add(list_desired_rows(list));
204            lines = lines.saturating_add(list.summary_line_rows(modal.footer_hint.as_deref()));
205        } else {
206            lines = lines.saturating_add(1);
207        }
208        if title_chrome_rows > 0 {
209            lines = lines.saturating_add(1 + usize::from(title_chrome_rows)); // title row + dividers
210        }
211        lines
212    } else {
213        return (area, None);
214    };
215
216    let max_panel_height = area.height.saturating_sub(1);
217    if max_panel_height == 0 {
218        return (area, None);
219    }
220
221    let min_height = ui::MODAL_MIN_HEIGHT.min(max_panel_height).max(1);
222    let modal_height_cap = if multiline_list_present {
223        MAX_INLINE_MODAL_HEIGHT_MULTILINE
224    } else {
225        MAX_INLINE_MODAL_HEIGHT
226    }
227    .saturating_add(title_chrome_rows);
228    let capped_max = modal_height_cap.min(max_panel_height).max(min_height);
229    let desired_height = (desired_lines.min(u16::MAX as usize) as u16)
230        .max(min_height)
231        .min(capped_max);
232
233    let chunks =
234        Layout::vertical([Constraint::Min(1), Constraint::Length(desired_height)]).split(area);
235    (chunks[0], Some(chunks[1]))
236}
237
238pub(crate) fn floating_modal_area(area: Rect) -> Rect {
239    if area.width == 0 || area.height == 0 {
240        return area;
241    }
242
243    let height = (area.height / 2).max(1);
244    let y = area.y.saturating_add(area.height.saturating_sub(height));
245    Rect::new(area.x, y, area.width, height)
246}
247
248pub fn render_modal(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
249    if area.width == 0 || area.height == 0 {
250        session.set_modal_list_area(None);
251        session.set_modal_text_areas(Vec::new());
252        session.set_modal_link_targets(Vec::new());
253        return;
254    }
255
256    // Auto-approve modals when skip_confirmations is set (for tests and headless mode)
257    if session.skip_confirmations
258        && let Some(mut modal) = session.take_modal_state()
259    {
260        if let Some(list) = &mut modal.list
261            && let Some(_selection) = list.current_selection()
262        {
263            // Note: We can't easily emit an event from here without access to the sender.
264            // Instead, we just clear the modal and assume the tool execution logic
265            // or whatever triggered the modal will check skip_confirmations as well.
266            // This is handled in ensure_tool_permission.
267        }
268        session.input_enabled = modal.restore_input;
269        session.cursor_visible = modal.restore_cursor;
270        session.needs_full_clear = true;
271        session.needs_redraw = true;
272        session.set_modal_list_area(None);
273        session.set_modal_text_areas(Vec::new());
274        session.set_modal_link_targets(Vec::new());
275        return;
276    }
277
278    let styles = modal_render_styles(session);
279    let input_styles = input_styles_from_theme(&session.theme);
280    render_modal_background(frame, area, styles.selectable);
281    let link_style = session
282        .styles
283        .transcript_link_style()
284        .add_modifier(Modifier::UNDERLINED);
285    let hovered_link_style = link_style.add_modifier(Modifier::BOLD);
286    let workspace_root = session.workspace_root.clone();
287    let last_mouse_position = session.last_mouse_position;
288    let title = modal_title_text(session).trim().to_owned();
289    let mut title_link_targets = Vec::new();
290    let (body_area, title_area) = if title.is_empty() {
291        (area, None)
292    } else {
293        let chunks = Layout::vertical([
294            Constraint::Length(1),
295            Constraint::Length(1),
296            Constraint::Min(0),
297            Constraint::Length(1),
298        ])
299        .split(area);
300        let title_area = chunks[0];
301        let top_divider_area = chunks[1];
302        let body_area = chunks[2];
303        let bottom_divider_area = chunks[3];
304        let title_line = Line::from(Span::styled(title, styles.title));
305        let (decorated_title, link_targets) = decorate_detected_link_lines(
306            vec![title_line],
307            title_area,
308            workspace_root.as_deref(),
309            last_mouse_position,
310            link_style,
311            hovered_link_style,
312        );
313        title_link_targets = link_targets;
314        render_modal_background(frame, title_area, styles.selectable);
315        frame.render_widget(
316            Paragraph::new(decorated_title)
317                .style(styles.title)
318                .wrap(Wrap { trim: true }),
319            title_area,
320        );
321        render_modal_divider(frame, top_divider_area, styles.border);
322        render_modal_divider(frame, bottom_divider_area, styles.border);
323        (body_area, Some(title_area))
324    };
325
326    if let Some(wizard) = session.wizard_overlay_mut() {
327        render_modal_background(frame, body_area, styles.selectable);
328        if body_area.width == 0 || body_area.height == 0 {
329            session.set_modal_list_area(None);
330            session.set_modal_text_areas(Vec::new());
331            session.set_modal_link_targets(Vec::new());
332            return;
333        }
334        let mut outcome = render_wizard_modal_body(
335            frame,
336            body_area,
337            wizard,
338            &styles,
339            &input_styles,
340            workspace_root.as_deref(),
341            last_mouse_position,
342            link_style,
343            hovered_link_style,
344        );
345        if let Some(title_area) = title_area {
346            outcome.text_areas.push(title_area);
347            outcome.link_targets.extend(title_link_targets.clone());
348        }
349        session.set_modal_list_area(outcome.list_area);
350        session.set_modal_text_areas(outcome.text_areas);
351        session.set_modal_link_targets(outcome.link_targets);
352        return;
353    }
354
355    let input = session.input_manager.content().to_owned();
356    let cursor = session.input_manager.cursor();
357    let Some(modal) = session.modal_state_mut() else {
358        session.set_modal_list_area(None);
359        session.set_modal_text_areas(Vec::new());
360        session.set_modal_link_targets(Vec::new());
361        return;
362    };
363
364    render_modal_background(frame, body_area, styles.selectable);
365    if body_area.width == 0 || body_area.height == 0 {
366        session.set_modal_list_area(None);
367        session.set_modal_text_areas(Vec::new());
368        session.set_modal_link_targets(Vec::new());
369        return;
370    }
371
372    if modal.is_help_modal {
373        use ratatui_cheese::help::{Binding, Help, HelpStyles};
374        let help = Help::default()
375            .show_all(true)
376            .styles(HelpStyles::from_palette(
377                &ratatui_cheese::theme::Palette::dark(),
378            ))
379            .bindings(vec![
380                Binding::new("?", "help"),
381                Binding::new("Enter", "submit"),
382                Binding::new("Ctrl+C", "interrupt"),
383                Binding::new("Esc", "clear/cancel"),
384                Binding::new("Tab", "accept/queue"),
385                Binding::new("Shift+Tab", "mode picker"),
386            ])
387            .binding_groups(vec![
388                vec![
389                    Binding::new("!cmd", "shell mode"),
390                    Binding::new("@path", "file reference"),
391                    Binding::new("Enter", "submit/queue"),
392                    Binding::new("Ctrl+Enter", "run/steer"),
393                    Binding::new("Shift+Enter", "new line"),
394                    Binding::new("Esc", "clear/cancel"),
395                    Binding::new("Ctrl+C", "interrupt/copy"),
396                    Binding::new("Ctrl+D", "exit"),
397                    Binding::new("PgUp/PgDn", "scroll"),
398                    Binding::new("Alt+S", "subprocesses"),
399                ],
400                vec![
401                    Binding::new("Ctrl+A/E", "line ends"),
402                    Binding::new("Ctrl+F/B", "char move"),
403                    Binding::new("Alt+F/B", "word move"),
404                    Binding::new("Alt+←/→", "word move"),
405                    Binding::new("Ctrl+P/N", "history"),
406                    Binding::new("Ctrl+R/S", "history search"),
407                    Binding::new("Ctrl+W", "delete prev word"),
408                    Binding::new("Alt+D", "delete next word"),
409                    Binding::new("Ctrl+U/K", "delete to edge"),
410                    Binding::new("Ctrl+T", "transpose"),
411                    Binding::new("Alt+U/L/C", "case change"),
412                ],
413                vec![
414                    Binding::new("/", "commands"),
415                    Binding::new("?", "shortcuts"),
416                    Binding::new("Shift+Tab", "mode picker"),
417                    Binding::new("Tab", "accept/queue"),
418                    Binding::new("Ctrl+L", "clear screen"),
419                    Binding::new("Ctrl+M", "model picker"),
420                    Binding::new("Ctrl+O", "copy response"),
421                    Binding::new("Alt+P", "prompt suggest"),
422                    Binding::new("Alt+O", "transcript review"),
423                    Binding::new("Ctrl+I", "lists"),
424                    Binding::new("Ctrl+G", "editor"),
425                    Binding::new("Ctrl+Z/Y", "undo/redo"),
426                ],
427            ]);
428        frame.render_widget(&help, body_area);
429        session.set_modal_list_area(None);
430        session.set_modal_text_areas(Vec::new());
431        session.set_modal_link_targets(Vec::new());
432        return;
433    }
434    let mut outcome = render_modal_body(
435        frame,
436        body_area,
437        ModalBodyContext {
438            instructions: &modal.lines,
439            footer_hint: modal.footer_hint.as_deref(),
440            list: modal.list.as_mut(),
441            styles: &styles,
442            secure_prompt: modal.secure_prompt.as_ref(),
443            search: modal.search.as_ref(),
444            input: &input,
445            cursor,
446            input_styles: &input_styles,
447        },
448        workspace_root.as_deref(),
449        last_mouse_position,
450        link_style,
451        hovered_link_style,
452    );
453    if let Some(title_area) = title_area {
454        outcome.text_areas.push(title_area);
455        outcome.link_targets.extend(title_link_targets);
456    }
457    session.set_modal_list_area(outcome.list_area);
458    session.set_modal_text_areas(outcome.text_areas);
459    session.set_modal_link_targets(outcome.link_targets);
460}
461
462pub(crate) fn modal_render_styles(session: &Session) -> ModalRenderStyles {
463    let default_style = modal_base_style(session);
464    let header_style = modal_heading_style(session);
465    let chrome_style = modal_chrome_style(session);
466    let chrome_border_style = session
467        .styles
468        .border_style()
469        .fg(ratatui_color_from_ansi(resolve_modal_chrome_ansi_color(
470            session,
471        )))
472        .remove_modifier(Modifier::DIM)
473        .add_modifier(Modifier::BOLD);
474    ModalRenderStyles {
475        border: chrome_border_style,
476        highlight: modal_list_highlight_style(session),
477        badge: default_style.add_modifier(Modifier::DIM | Modifier::BOLD),
478        header: header_style,
479        selectable: default_style.add_modifier(Modifier::DIM),
480        detail: default_style.add_modifier(Modifier::DIM),
481        search_match: header_style.add_modifier(Modifier::UNDERLINED),
482        title: chrome_style,
483        divider: default_style.add_modifier(Modifier::DIM),
484        instruction_border: chrome_border_style,
485        instruction_title: header_style,
486        instruction_bullet: header_style,
487        instruction_body: default_style,
488        hint: default_style.add_modifier(Modifier::DIM | Modifier::ITALIC),
489    }
490}
491
492#[expect(dead_code)]
493pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
494    let trimmed = text.trim();
495    let stripped = trimmed
496        .strip_prefix("```")
497        .or_else(|| trimmed.strip_prefix("~~~"));
498
499    let Some(rest) = stripped else {
500        return false;
501    };
502
503    if rest.contains("```") || rest.contains("~~~") {
504        return false;
505    }
506
507    if session.in_tool_code_fence {
508        session.in_tool_code_fence = false;
509        remove_trailing_empty_tool_line(session);
510    } else {
511        session.in_tool_code_fence = true;
512    }
513
514    true
515}
516
517#[expect(dead_code)]
518fn remove_trailing_empty_tool_line(session: &mut Session) {
519    let should_remove = session
520        .lines
521        .last()
522        .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
523        .unwrap_or(false);
524    if should_remove {
525        session.lines.pop();
526        session.invalidate_scroll_metrics();
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::ui::tui::InlineTheme;
534    use ratatui::style::Color;
535
536    #[test]
537    fn modal_title_text_uses_modal_title_and_empty_default() {
538        let mut session = Session::new(InlineTheme::default(), None, 20);
539        assert_eq!(modal_title_text(&session), "");
540
541        session.show_modal("Config".to_owned(), vec![], None);
542        assert_eq!(modal_title_text(&session), "Config");
543    }
544
545    #[test]
546    fn modal_title_style_uses_explicit_chrome_color() {
547        let session = Session::new(InlineTheme::default(), None, 20);
548        let styles = modal_render_styles(&session);
549
550        assert_eq!(
551            styles.title.fg,
552            Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
553        );
554        assert!(styles.title.bg.is_none());
555        assert_eq!(
556            styles.border.fg,
557            Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
558        );
559        assert!(styles.title.add_modifier.contains(Modifier::BOLD));
560    }
561
562    #[test]
563    fn modal_section_headers_use_chrome_color_on_base_background() {
564        let theme = InlineTheme {
565            foreground: Some(AnsiColorEnum::Ansi256(Ansi256Color(16))),
566            background: Some(AnsiColorEnum::Ansi256(Ansi256Color(231))),
567            primary: Some(AnsiColorEnum::Ansi256(Ansi256Color(117))),
568            ..InlineTheme::default()
569        };
570        let session = Session::new(theme, None, 20);
571        let styles = modal_render_styles(&session);
572
573        assert_eq!(styles.header.fg, Some(Color::Indexed(117)));
574        assert_eq!(styles.header.bg, Some(Color::Indexed(231)));
575        assert_eq!(styles.instruction_title.fg, Some(Color::Indexed(117)));
576        assert_eq!(styles.instruction_title.bg, Some(Color::Indexed(231)));
577        assert!(styles.header.add_modifier.contains(Modifier::BOLD));
578    }
579
580    #[test]
581    fn floating_modal_area_uses_bottom_half_of_viewport() {
582        let area = floating_modal_area(Rect::new(3, 5, 80, 31));
583
584        assert_eq!(area, Rect::new(3, 21, 80, 15));
585    }
586
587    #[test]
588    fn floating_modal_area_uses_exact_half_for_even_height() {
589        let area = floating_modal_area(Rect::new(0, 0, 80, 30));
590
591        assert_eq!(area, Rect::new(0, 15, 80, 15));
592    }
593
594    #[test]
595    fn floating_modal_area_preserves_single_row_viewport() {
596        let area = floating_modal_area(Rect::new(0, 0, 80, 1));
597
598        assert_eq!(area, Rect::new(0, 0, 80, 1));
599    }
600}