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};
6use crate::ui::tui::types::InlineListSelection;
7
8const MAX_INLINE_MODAL_HEIGHT: u16 = 20;
9const MAX_INLINE_MODAL_HEIGHT_MULTILINE: u16 = 32;
10const MAX_INLINE_INSTRUCTION_ROWS: usize = 6;
11
12fn list_has_two_line_items(list: &ModalListState) -> bool {
13    list.visible_indices.iter().any(|&index| {
14        list.items.get(index).is_some_and(|item| {
15            item.subtitle
16                .as_ref()
17                .is_some_and(|subtitle| !subtitle.trim().is_empty())
18        })
19    })
20}
21
22fn list_row_cap(list: &ModalListState) -> usize {
23    if list_has_two_line_items(list) {
24        ui::INLINE_LIST_MAX_ROWS_MULTILINE
25    } else {
26        ui::INLINE_LIST_MAX_ROWS
27    }
28}
29
30fn list_desired_rows(list: &ModalListState) -> usize {
31    list.visible_indices.len().clamp(1, list_row_cap(list))
32}
33
34fn modal_title_text(session: &Session) -> &str {
35    session
36        .wizard_modal
37        .as_ref()
38        .map(|wizard| wizard.title.as_str())
39        .or_else(|| session.modal.as_ref().map(|modal| modal.title.as_str()))
40        .unwrap_or("")
41}
42
43fn modal_has_title(session: &Session) -> bool {
44    !modal_title_text(session).trim().is_empty()
45}
46
47fn wizard_step_has_inline_custom_editor(
48    wizard: &crate::ui::tui::session::modal::WizardModalState,
49) -> bool {
50    let Some(step) = wizard.steps.get(wizard.current_step) else {
51        return false;
52    };
53    let Some(selected_visible) = step.list.list_state.selected() else {
54        return false;
55    };
56    let Some(&item_index) = step.list.visible_indices.get(selected_visible) else {
57        return false;
58    };
59    let Some(item) = step.list.items.get(item_index) else {
60        return false;
61    };
62    matches!(
63        item.selection.as_ref(),
64        Some(InlineListSelection::RequestUserInputAnswer {
65            selected,
66            other,
67            ..
68        }) if selected.is_empty() && other.is_some()
69    )
70}
71
72pub fn split_inline_modal_area(session: &Session, area: Rect) -> (Rect, Option<Rect>) {
73    if area.width == 0 || area.height == 0 {
74        return (area, None);
75    }
76
77    let multiline_list_present = if let Some(wizard) = session.wizard_modal.as_ref() {
78        wizard
79            .steps
80            .get(wizard.current_step)
81            .is_some_and(|step| list_has_two_line_items(&step.list))
82    } else if let Some(modal) = session.modal.as_ref() {
83        modal.list.as_ref().is_some_and(list_has_two_line_items)
84    } else {
85        false
86    };
87
88    let desired_lines = if let Some(wizard) = session.wizard_modal.as_ref() {
89        let mut lines = 0usize;
90        lines = lines.saturating_add(1); // tabs/header
91        if wizard.search.is_some() {
92            lines = lines.saturating_add(1);
93        }
94        lines = lines.saturating_add(2); // question and spacing
95        let list_rows = wizard
96            .steps
97            .get(wizard.current_step)
98            .map(|step| list_desired_rows(&step.list))
99            .unwrap_or(1);
100        lines = lines.saturating_add(list_rows);
101        if wizard
102            .steps
103            .get(wizard.current_step)
104            .is_some_and(|step| step.notes_active || !step.notes.is_empty())
105            && !wizard_step_has_inline_custom_editor(wizard)
106        {
107            lines = lines.saturating_add(1);
108        }
109        lines = lines.saturating_add(
110            wizard
111                .instruction_lines()
112                .len()
113                .min(MAX_INLINE_INSTRUCTION_ROWS),
114        );
115        if modal_has_title(session) {
116            lines = lines.saturating_add(1); // title row
117        }
118        lines
119    } else if let Some(modal) = session.modal.as_ref() {
120        let mut lines = modal.lines.len().clamp(1, MAX_INLINE_INSTRUCTION_ROWS);
121        if modal.search.is_some() {
122            lines = lines.saturating_add(1);
123        }
124        if modal.secure_prompt.is_some() {
125            lines = lines.saturating_add(2);
126        }
127        if let Some(list) = modal.list.as_ref() {
128            lines = lines.saturating_add(list_desired_rows(list));
129        } else {
130            lines = lines.saturating_add(1);
131        }
132        if modal_has_title(session) {
133            lines = lines.saturating_add(1); // title row
134        }
135        lines
136    } else {
137        return (area, None);
138    };
139
140    let max_panel_height = area.height.saturating_sub(1);
141    if max_panel_height == 0 {
142        return (area, None);
143    }
144
145    let min_height = ui::MODAL_MIN_HEIGHT.min(max_panel_height).max(1);
146    let modal_height_cap = if multiline_list_present {
147        MAX_INLINE_MODAL_HEIGHT_MULTILINE
148    } else {
149        MAX_INLINE_MODAL_HEIGHT
150    };
151    let capped_max = modal_height_cap.min(max_panel_height).max(min_height);
152    let desired_height = (desired_lines.min(u16::MAX as usize) as u16)
153        .max(min_height)
154        .min(capped_max);
155
156    let chunks =
157        Layout::vertical([Constraint::Min(1), Constraint::Length(desired_height)]).split(area);
158    (chunks[0], Some(chunks[1]))
159}
160
161pub fn render_modal(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
162    if area.width == 0 || area.height == 0 {
163        return;
164    }
165
166    // Auto-approve modals when skip_confirmations is set (for tests and headless mode)
167    if session.skip_confirmations
168        && let Some(mut modal) = session.modal.take()
169    {
170        if let Some(list) = &mut modal.list
171            && let Some(_selection) = list.current_selection()
172        {
173            // Note: We can't easily emit an event from here without access to the sender.
174            // Instead, we just clear the modal and assume the tool execution logic
175            // or whatever triggered the modal will check skip_confirmations as well.
176            // This is handled in ensure_tool_permission.
177        }
178        session.input_enabled = modal.restore_input;
179        session.cursor_visible = modal.restore_cursor;
180        session.needs_full_clear = true;
181        session.needs_redraw = true;
182        return;
183    }
184
185    let styles = modal_render_styles(session);
186    let title = modal_title_text(session).trim().to_owned();
187    let body_area = if title.is_empty() {
188        area
189    } else {
190        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
191        let title_area = chunks[0];
192        frame.render_widget(Clear, title_area);
193        frame.render_widget(
194            Paragraph::new(Line::from(Span::styled(title, styles.title))).wrap(Wrap { trim: true }),
195            title_area,
196        );
197        chunks[1]
198    };
199
200    if let Some(wizard) = session.wizard_modal.as_mut() {
201        frame.render_widget(Clear, body_area);
202        if body_area.width == 0 || body_area.height == 0 {
203            return;
204        }
205        render_wizard_modal_body(frame, body_area, wizard, &styles);
206        return;
207    }
208
209    let Some(modal) = session.modal.as_mut() else {
210        return;
211    };
212
213    frame.render_widget(Clear, body_area);
214    if body_area.width == 0 || body_area.height == 0 {
215        return;
216    }
217    render_modal_body(
218        frame,
219        body_area,
220        ModalBodyContext {
221            instructions: &modal.lines,
222            footer_hint: modal.footer_hint.as_deref(),
223            list: modal.list.as_mut(),
224            styles: &styles,
225            secure_prompt: modal.secure_prompt.as_ref(),
226            search: modal.search.as_ref(),
227            input: session.input_manager.content(),
228            cursor: session.input_manager.cursor(),
229        },
230    );
231}
232
233fn modal_render_styles(session: &Session) -> ModalRenderStyles {
234    ModalRenderStyles {
235        border: border_style(session),
236        highlight: modal_list_highlight_style(session),
237        badge: border_style(session).add_modifier(Modifier::DIM | Modifier::BOLD),
238        header: accent_style(session).add_modifier(Modifier::BOLD),
239        selectable: default_style(session),
240        detail: default_style(session).add_modifier(Modifier::DIM),
241        search_match: accent_style(session).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
242        title: accent_style(session).add_modifier(Modifier::BOLD),
243        divider: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
244        instruction_border: border_style(session),
245        instruction_title: session.section_title_style(),
246        instruction_bullet: accent_style(session).add_modifier(Modifier::BOLD),
247        instruction_body: default_style(session),
248        hint: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
249    }
250}
251
252#[allow(dead_code)]
253pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
254    let trimmed = text.trim();
255    let stripped = trimmed
256        .strip_prefix("```")
257        .or_else(|| trimmed.strip_prefix("~~~"));
258
259    let Some(rest) = stripped else {
260        return false;
261    };
262
263    if rest.contains("```") || rest.contains("~~~") {
264        return false;
265    }
266
267    if session.in_tool_code_fence {
268        session.in_tool_code_fence = false;
269        remove_trailing_empty_tool_line(session);
270    } else {
271        session.in_tool_code_fence = true;
272    }
273
274    true
275}
276
277#[allow(dead_code)]
278fn remove_trailing_empty_tool_line(session: &mut Session) {
279    let should_remove = session
280        .lines
281        .last()
282        .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
283        .unwrap_or(false);
284    if should_remove {
285        session.lines.pop();
286        invalidate_scroll_metrics(session);
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::ui::tui::InlineTheme;
294
295    #[test]
296    fn modal_title_text_uses_modal_title_and_empty_default() {
297        let mut session = Session::new(InlineTheme::default(), None, 20);
298        assert_eq!(modal_title_text(&session), "");
299
300        session.show_modal("Config".to_owned(), vec![], None);
301        assert_eq!(modal_title_text(&session), "Config");
302    }
303
304    #[test]
305    fn modal_title_style_is_accent_and_bold() {
306        let session = Session::new(InlineTheme::default(), None, 20);
307        let styles = modal_render_styles(&session);
308
309        assert_eq!(
310            styles.title,
311            accent_style(&session).add_modifier(Modifier::BOLD)
312        );
313    }
314}