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_overlay()
37        .map(|wizard| wizard.title.as_str())
38        .or_else(|| session.modal_state().map(|modal| modal.title.as_str()))
39        .unwrap_or("")
40}
41
42fn modal_has_title(session: &Session) -> bool {
43    !modal_title_text(session).trim().is_empty()
44}
45
46fn wizard_step_has_inline_custom_editor(
47    wizard: &crate::ui::tui::session::modal::WizardModalState,
48) -> bool {
49    let Some(step) = wizard.steps.get(wizard.current_step) else {
50        return false;
51    };
52    let Some(selected_visible) = step.list.list_state.selected() else {
53        return false;
54    };
55    let Some(&item_index) = step.list.visible_indices.get(selected_visible) else {
56        return false;
57    };
58    let Some(item) = step.list.items.get(item_index) else {
59        return false;
60    };
61    matches!(
62        item.selection.as_ref(),
63        Some(InlineListSelection::RequestUserInputAnswer {
64            selected,
65            other,
66            ..
67        }) if selected.is_empty() && other.is_some()
68    )
69}
70
71pub fn split_inline_modal_area(session: &Session, area: Rect) -> (Rect, Option<Rect>) {
72    if area.width == 0 || area.height == 0 {
73        return (area, None);
74    }
75
76    let multiline_list_present = if let Some(wizard) = session.wizard_overlay() {
77        wizard
78            .steps
79            .get(wizard.current_step)
80            .is_some_and(|step| list_has_two_line_items(&step.list))
81    } else if let Some(modal) = session.modal_state() {
82        modal.list.as_ref().is_some_and(list_has_two_line_items)
83    } else {
84        false
85    };
86
87    let desired_lines = if let Some(wizard) = session.wizard_overlay() {
88        let mut lines = 0usize;
89        lines = lines.saturating_add(1); // tabs/header
90        if wizard.search.is_some() {
91            lines = lines.saturating_add(1);
92        }
93        lines = lines.saturating_add(2); // question and spacing
94        let list_rows = wizard
95            .steps
96            .get(wizard.current_step)
97            .map(|step| list_desired_rows(&step.list))
98            .unwrap_or(1);
99        lines = lines.saturating_add(list_rows);
100        if wizard
101            .steps
102            .get(wizard.current_step)
103            .is_some_and(|step| step.notes_active || !step.notes.is_empty())
104            && !wizard_step_has_inline_custom_editor(wizard)
105        {
106            lines = lines.saturating_add(1);
107        }
108        lines = lines.saturating_add(
109            wizard
110                .instruction_lines()
111                .len()
112                .min(MAX_INLINE_INSTRUCTION_ROWS),
113        );
114        if modal_has_title(session) {
115            lines = lines.saturating_add(1); // title row
116        }
117        lines
118    } else if let Some(modal) = session.modal_state() {
119        let mut lines = modal.lines.len().clamp(1, MAX_INLINE_INSTRUCTION_ROWS);
120        if modal.search.is_some() {
121            lines = lines.saturating_add(1);
122        }
123        if modal.secure_prompt.is_some() {
124            lines = lines.saturating_add(2);
125        }
126        if let Some(list) = modal.list.as_ref() {
127            lines = lines.saturating_add(list_desired_rows(list));
128        } else {
129            lines = lines.saturating_add(1);
130        }
131        if modal_has_title(session) {
132            lines = lines.saturating_add(1); // title row
133        }
134        lines
135    } else {
136        return (area, None);
137    };
138
139    let max_panel_height = area.height.saturating_sub(1);
140    if max_panel_height == 0 {
141        return (area, None);
142    }
143
144    let min_height = ui::MODAL_MIN_HEIGHT.min(max_panel_height).max(1);
145    let modal_height_cap = if multiline_list_present {
146        MAX_INLINE_MODAL_HEIGHT_MULTILINE
147    } else {
148        MAX_INLINE_MODAL_HEIGHT
149    };
150    let capped_max = modal_height_cap.min(max_panel_height).max(min_height);
151    let desired_height = (desired_lines.min(u16::MAX as usize) as u16)
152        .max(min_height)
153        .min(capped_max);
154
155    let chunks =
156        Layout::vertical([Constraint::Min(1), Constraint::Length(desired_height)]).split(area);
157    (chunks[0], Some(chunks[1]))
158}
159
160pub fn render_modal(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
161    if area.width == 0 || area.height == 0 {
162        session.set_modal_list_area(None);
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.take_modal_state()
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        session.set_modal_list_area(None);
183        return;
184    }
185
186    let styles = modal_render_styles(session);
187    let title = modal_title_text(session).trim().to_owned();
188    let body_area = if title.is_empty() {
189        area
190    } else {
191        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
192        let title_area = chunks[0];
193        frame.render_widget(Clear, title_area);
194        frame.render_widget(
195            Paragraph::new(Line::from(Span::styled(title, styles.title))).wrap(Wrap { trim: true }),
196            title_area,
197        );
198        chunks[1]
199    };
200
201    if let Some(wizard) = session.wizard_overlay_mut() {
202        frame.render_widget(Clear, body_area);
203        if body_area.width == 0 || body_area.height == 0 {
204            session.set_modal_list_area(None);
205            return;
206        }
207        let list_area = render_wizard_modal_body(frame, body_area, wizard, &styles);
208        session.set_modal_list_area(list_area);
209        return;
210    }
211
212    let input = session.input_manager.content().to_owned();
213    let cursor = session.input_manager.cursor();
214    let Some(modal) = session.modal_state_mut() else {
215        session.set_modal_list_area(None);
216        return;
217    };
218
219    frame.render_widget(Clear, body_area);
220    if body_area.width == 0 || body_area.height == 0 {
221        session.set_modal_list_area(None);
222        return;
223    }
224    let list_area = render_modal_body(
225        frame,
226        body_area,
227        ModalBodyContext {
228            instructions: &modal.lines,
229            footer_hint: modal.footer_hint.as_deref(),
230            list: modal.list.as_mut(),
231            styles: &styles,
232            secure_prompt: modal.secure_prompt.as_ref(),
233            search: modal.search.as_ref(),
234            input: &input,
235            cursor,
236        },
237    );
238    session.set_modal_list_area(list_area);
239}
240
241pub(crate) fn modal_render_styles(session: &Session) -> ModalRenderStyles {
242    ModalRenderStyles {
243        border: border_style(session),
244        highlight: modal_list_highlight_style(session),
245        badge: border_style(session).add_modifier(Modifier::DIM | Modifier::BOLD),
246        header: accent_style(session).add_modifier(Modifier::BOLD),
247        selectable: default_style(session),
248        detail: default_style(session).add_modifier(Modifier::DIM),
249        search_match: accent_style(session).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
250        title: accent_style(session).add_modifier(Modifier::BOLD),
251        divider: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
252        instruction_border: border_style(session),
253        instruction_title: session.section_title_style(),
254        instruction_bullet: accent_style(session).add_modifier(Modifier::BOLD),
255        instruction_body: default_style(session),
256        hint: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
257    }
258}
259
260#[allow(dead_code)]
261pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
262    let trimmed = text.trim();
263    let stripped = trimmed
264        .strip_prefix("```")
265        .or_else(|| trimmed.strip_prefix("~~~"));
266
267    let Some(rest) = stripped else {
268        return false;
269    };
270
271    if rest.contains("```") || rest.contains("~~~") {
272        return false;
273    }
274
275    if session.in_tool_code_fence {
276        session.in_tool_code_fence = false;
277        remove_trailing_empty_tool_line(session);
278    } else {
279        session.in_tool_code_fence = true;
280    }
281
282    true
283}
284
285#[allow(dead_code)]
286fn remove_trailing_empty_tool_line(session: &mut Session) {
287    let should_remove = session
288        .lines
289        .last()
290        .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
291        .unwrap_or(false);
292    if should_remove {
293        session.lines.pop();
294        invalidate_scroll_metrics(session);
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::ui::tui::InlineTheme;
302
303    #[test]
304    fn modal_title_text_uses_modal_title_and_empty_default() {
305        let mut session = Session::new(InlineTheme::default(), None, 20);
306        assert_eq!(modal_title_text(&session), "");
307
308        session.show_modal("Config".to_owned(), vec![], None);
309        assert_eq!(modal_title_text(&session), "Config");
310    }
311
312    #[test]
313    fn modal_title_style_is_accent_and_bold() {
314        let session = Session::new(InlineTheme::default(), None, 20);
315        let styles = modal_render_styles(&session);
316
317        assert_eq!(
318            styles.title,
319            accent_style(&session).add_modifier(Modifier::BOLD)
320        );
321    }
322}