Skip to main content

vtcode_tui/core_tui/session/render/
modal_renderer.rs

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