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_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); if wizard.search.is_some() {
91 lines = lines.saturating_add(1);
92 }
93 lines = lines.saturating_add(2); 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); }
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); }
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 return;
163 }
164
165 if session.skip_confirmations
167 && let Some(mut modal) = session.take_modal_state()
168 {
169 if let Some(list) = &mut modal.list
170 && let Some(_selection) = list.current_selection()
171 {
172 }
177 session.input_enabled = modal.restore_input;
178 session.cursor_visible = modal.restore_cursor;
179 session.needs_full_clear = true;
180 session.needs_redraw = true;
181 return;
182 }
183
184 let styles = modal_render_styles(session);
185 let title = modal_title_text(session).trim().to_owned();
186 let body_area = if title.is_empty() {
187 area
188 } else {
189 let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
190 let title_area = chunks[0];
191 frame.render_widget(Clear, title_area);
192 frame.render_widget(
193 Paragraph::new(Line::from(Span::styled(title, styles.title))).wrap(Wrap { trim: true }),
194 title_area,
195 );
196 chunks[1]
197 };
198
199 if let Some(wizard) = session.wizard_overlay_mut() {
200 frame.render_widget(Clear, body_area);
201 if body_area.width == 0 || body_area.height == 0 {
202 return;
203 }
204 render_wizard_modal_body(frame, body_area, wizard, &styles);
205 return;
206 }
207
208 let input = session.input_manager.content().to_owned();
209 let cursor = session.input_manager.cursor();
210 let Some(modal) = session.modal_state_mut() else {
211 return;
212 };
213
214 frame.render_widget(Clear, body_area);
215 if body_area.width == 0 || body_area.height == 0 {
216 return;
217 }
218 render_modal_body(
219 frame,
220 body_area,
221 ModalBodyContext {
222 instructions: &modal.lines,
223 footer_hint: modal.footer_hint.as_deref(),
224 list: modal.list.as_mut(),
225 styles: &styles,
226 secure_prompt: modal.secure_prompt.as_ref(),
227 search: modal.search.as_ref(),
228 input: &input,
229 cursor,
230 },
231 );
232}
233
234fn modal_render_styles(session: &Session) -> ModalRenderStyles {
235 ModalRenderStyles {
236 border: border_style(session),
237 highlight: modal_list_highlight_style(session),
238 badge: border_style(session).add_modifier(Modifier::DIM | Modifier::BOLD),
239 header: accent_style(session).add_modifier(Modifier::BOLD),
240 selectable: default_style(session),
241 detail: default_style(session).add_modifier(Modifier::DIM),
242 search_match: accent_style(session).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
243 title: accent_style(session).add_modifier(Modifier::BOLD),
244 divider: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
245 instruction_border: border_style(session),
246 instruction_title: session.section_title_style(),
247 instruction_bullet: accent_style(session).add_modifier(Modifier::BOLD),
248 instruction_body: default_style(session),
249 hint: default_style(session).add_modifier(Modifier::DIM | Modifier::ITALIC),
250 }
251}
252
253#[allow(dead_code)]
254pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
255 let trimmed = text.trim();
256 let stripped = trimmed
257 .strip_prefix("```")
258 .or_else(|| trimmed.strip_prefix("~~~"));
259
260 let Some(rest) = stripped else {
261 return false;
262 };
263
264 if rest.contains("```") || rest.contains("~~~") {
265 return false;
266 }
267
268 if session.in_tool_code_fence {
269 session.in_tool_code_fence = false;
270 remove_trailing_empty_tool_line(session);
271 } else {
272 session.in_tool_code_fence = true;
273 }
274
275 true
276}
277
278#[allow(dead_code)]
279fn remove_trailing_empty_tool_line(session: &mut Session) {
280 let should_remove = session
281 .lines
282 .last()
283 .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
284 .unwrap_or(false);
285 if should_remove {
286 session.lines.pop();
287 invalidate_scroll_metrics(session);
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::ui::tui::InlineTheme;
295
296 #[test]
297 fn modal_title_text_uses_modal_title_and_empty_default() {
298 let mut session = Session::new(InlineTheme::default(), None, 20);
299 assert_eq!(modal_title_text(&session), "");
300
301 session.show_modal("Config".to_owned(), vec![], None);
302 assert_eq!(modal_title_text(&session), "Config");
303 }
304
305 #[test]
306 fn modal_title_style_is_accent_and_bold() {
307 let session = Session::new(InlineTheme::default(), None, 20);
308 let styles = modal_render_styles(&session);
309
310 assert_eq!(
311 styles.title,
312 accent_style(&session).add_modifier(Modifier::BOLD)
313 );
314 }
315}