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