Skip to main content

vtcode_tui/core_tui/session/render/
modal_renderer.rs

1use super::*;
2use crate::ui::tui::session::modal::{
3    ModalBodyContext, ModalListState, ModalRenderStyles, render_modal_body,
4    render_wizard_modal_body,
5};
6
7const MAX_INLINE_MODAL_HEIGHT: u16 = 20;
8const MAX_INLINE_MODAL_HEIGHT_MULTILINE: u16 = 32;
9const MAX_INLINE_INSTRUCTION_ROWS: usize = 6;
10
11fn list_has_two_line_items(list: &ModalListState) -> bool {
12    list.visible_indices.iter().any(|&index| {
13        list.items.get(index).is_some_and(|item| {
14            item.subtitle
15                .as_ref()
16                .is_some_and(|subtitle| !subtitle.trim().is_empty())
17        })
18    })
19}
20
21fn list_row_cap(list: &ModalListState) -> usize {
22    if list_has_two_line_items(list) {
23        ui::INLINE_LIST_MAX_ROWS_MULTILINE
24    } else {
25        ui::INLINE_LIST_MAX_ROWS
26    }
27}
28
29fn list_desired_rows(list: &ModalListState) -> usize {
30    list.visible_indices.len().clamp(1, list_row_cap(list))
31}
32
33pub fn split_inline_modal_area(session: &Session, area: Rect) -> (Rect, Option<Rect>) {
34    if area.width == 0 || area.height == 0 {
35        return (area, None);
36    }
37
38    let multiline_list_present = if let Some(wizard) = session.wizard_modal.as_ref() {
39        wizard
40            .steps
41            .get(wizard.current_step)
42            .is_some_and(|step| list_has_two_line_items(&step.list))
43    } else if let Some(modal) = session.modal.as_ref() {
44        modal.list.as_ref().is_some_and(list_has_two_line_items)
45    } else {
46        false
47    };
48
49    let desired_lines = if let Some(wizard) = session.wizard_modal.as_ref() {
50        let mut lines = 0usize;
51        lines = lines.saturating_add(1); // tabs/header
52        if wizard.search.is_some() {
53            lines = lines.saturating_add(1);
54        }
55        lines = lines.saturating_add(2); // question and spacing
56        let list_rows = wizard
57            .steps
58            .get(wizard.current_step)
59            .map(|step| list_desired_rows(&step.list))
60            .unwrap_or(1);
61        lines = lines.saturating_add(list_rows);
62        if wizard
63            .steps
64            .get(wizard.current_step)
65            .is_some_and(|step| step.notes_active || !step.notes.is_empty())
66        {
67            lines = lines.saturating_add(1);
68        }
69        lines = lines.saturating_add(
70            wizard
71                .instruction_lines()
72                .len()
73                .min(MAX_INLINE_INSTRUCTION_ROWS),
74        );
75        lines
76    } else if let Some(modal) = session.modal.as_ref() {
77        let mut lines = modal.lines.len().clamp(1, MAX_INLINE_INSTRUCTION_ROWS);
78        if modal.search.is_some() {
79            lines = lines.saturating_add(1);
80        }
81        if modal.secure_prompt.is_some() {
82            lines = lines.saturating_add(2);
83        }
84        if let Some(list) = modal.list.as_ref() {
85            lines = lines.saturating_add(list_desired_rows(list));
86        } else {
87            lines = lines.saturating_add(1);
88        }
89        lines
90    } else {
91        return (area, None);
92    };
93
94    let max_panel_height = area.height.saturating_sub(1);
95    if max_panel_height == 0 {
96        return (area, None);
97    }
98
99    let min_height = ui::MODAL_MIN_HEIGHT.min(max_panel_height).max(1);
100    let modal_height_cap = if multiline_list_present {
101        MAX_INLINE_MODAL_HEIGHT_MULTILINE
102    } else {
103        MAX_INLINE_MODAL_HEIGHT
104    };
105    let capped_max = modal_height_cap.min(max_panel_height).max(min_height);
106    let desired_height = (desired_lines.min(u16::MAX as usize) as u16)
107        .max(min_height)
108        .min(capped_max);
109
110    let chunks =
111        Layout::vertical([Constraint::Min(1), Constraint::Length(desired_height)]).split(area);
112    (chunks[0], Some(chunks[1]))
113}
114
115pub fn render_modal(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
116    if area.width == 0 || area.height == 0 {
117        return;
118    }
119
120    // Auto-approve modals when skip_confirmations is set (for tests and headless mode)
121    if session.skip_confirmations
122        && let Some(mut modal) = session.modal.take()
123    {
124        if let Some(list) = &mut modal.list
125            && let Some(_selection) = list.current_selection()
126        {
127            // Note: We can't easily emit an event from here without access to the sender.
128            // Instead, we just clear the modal and assume the tool execution logic
129            // or whatever triggered the modal will check skip_confirmations as well.
130            // This is handled in ensure_tool_permission.
131        }
132        session.input_enabled = modal.restore_input;
133        session.cursor_visible = modal.restore_cursor;
134        session.needs_full_clear = true;
135        session.needs_redraw = true;
136        return;
137    }
138
139    let styles = modal_render_styles(session);
140    if let Some(wizard) = session.wizard_modal.as_mut() {
141        frame.render_widget(Clear, area);
142        if area.width == 0 || area.height == 0 {
143            return;
144        }
145        render_wizard_modal_body(frame, area, wizard, &styles);
146        return;
147    }
148
149    let Some(modal) = session.modal.as_mut() else {
150        return;
151    };
152
153    frame.render_widget(Clear, area);
154    if area.width == 0 || area.height == 0 {
155        return;
156    }
157    render_modal_body(
158        frame,
159        area,
160        ModalBodyContext {
161            instructions: &modal.lines,
162            footer_hint: modal.footer_hint.as_deref(),
163            list: modal.list.as_mut(),
164            styles: &styles,
165            secure_prompt: modal.secure_prompt.as_ref(),
166            search: modal.search.as_ref(),
167            input: session.input_manager.content(),
168            cursor: session.input_manager.cursor(),
169        },
170    );
171}
172
173fn modal_render_styles(session: &Session) -> ModalRenderStyles {
174    ModalRenderStyles {
175        border: border_style(session),
176        highlight: modal_list_highlight_style(session),
177        badge: border_style(session).add_modifier(Modifier::DIM | Modifier::BOLD),
178        header: accent_style(session).add_modifier(Modifier::BOLD),
179        selectable: default_style(session),
180        detail: default_style(session).add_modifier(Modifier::DIM),
181        search_match: accent_style(session).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
182        title: Style::default().add_modifier(Modifier::BOLD),
183        divider: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
184        instruction_border: border_style(session),
185        instruction_title: session.section_title_style(),
186        instruction_bullet: accent_style(session).add_modifier(Modifier::BOLD),
187        instruction_body: default_style(session),
188        hint: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
189    }
190}
191
192#[allow(dead_code)]
193pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
194    let trimmed = text.trim();
195    let stripped = trimmed
196        .strip_prefix("```")
197        .or_else(|| trimmed.strip_prefix("~~~"));
198
199    let Some(rest) = stripped else {
200        return false;
201    };
202
203    if rest.contains("```") || rest.contains("~~~") {
204        return false;
205    }
206
207    if session.in_tool_code_fence {
208        session.in_tool_code_fence = false;
209        remove_trailing_empty_tool_line(session);
210    } else {
211        session.in_tool_code_fence = true;
212    }
213
214    true
215}
216
217#[allow(dead_code)]
218fn remove_trailing_empty_tool_line(session: &mut Session) {
219    let should_remove = session
220        .lines
221        .last()
222        .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
223        .unwrap_or(false);
224    if should_remove {
225        session.lines.pop();
226        invalidate_scroll_metrics(session);
227    }
228}