vtcode_tui/core_tui/session/render/
modal_renderer.rs1use 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); if wizard.search.is_some() {
94 lines = lines.saturating_add(1);
95 }
96 lines = lines.saturating_add(2); 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); }
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); }
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 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 }
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}