1use super::*;
2use crate::config::constants::ui;
3use crate::core_tui::session::list_panel::input_styles_from_theme;
4use crate::core_tui::session::transcript_links::decorate_detected_link_lines;
5use crate::core_tui::style::ratatui_color_from_ansi;
6use crate::core_tui::types::InlineMessageKind;
7use crate::ui::tui::session::modal::{
8 ModalBodyContext, ModalListState, ModalRenderStyles, render_modal_body,
9 render_wizard_modal_body,
10};
11use crate::ui::tui::types::InlineListSelection;
12use anstyle::{Ansi256Color, Color as AnsiColorEnum};
13use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
14
15const MAX_INLINE_MODAL_HEIGHT: u16 = 20;
16const MAX_INLINE_MODAL_HEIGHT_MULTILINE: u16 = 32;
17const MAX_INLINE_INSTRUCTION_ROWS: usize = 6;
18const MODAL_TITLE_CHROME_ROWS: usize = 2;
19
20fn modal_base_style(session: &Session) -> Style {
21 session.styles.default_style()
22}
23
24fn modal_heading_style(session: &Session) -> Style {
25 modal_base_style(session)
26 .fg(ratatui_color_from_ansi(resolve_modal_chrome_ansi_color(
27 session,
28 )))
29 .add_modifier(Modifier::BOLD)
30}
31
32fn list_has_two_line_items(list: &ModalListState) -> bool {
33 list.visible_indices.iter().any(|&index| {
34 list.items.get(index).is_some_and(|item| {
35 item.subtitle
36 .as_ref()
37 .is_some_and(|subtitle| !subtitle.trim().is_empty())
38 })
39 })
40}
41
42fn list_row_cap(list: &ModalListState) -> usize {
43 if list_has_two_line_items(list) {
44 ui::INLINE_LIST_MAX_ROWS_MULTILINE
45 } else {
46 ui::INLINE_LIST_MAX_ROWS
47 }
48}
49
50fn list_desired_rows(list: &ModalListState) -> usize {
51 list.visible_indices.len().clamp(1, list_row_cap(list))
52}
53
54fn modal_title_text(session: &Session) -> &str {
55 session
56 .wizard_overlay()
57 .map(|wizard| wizard.title.as_str())
58 .or_else(|| session.modal_state().map(|modal| modal.title.as_str()))
59 .unwrap_or("")
60}
61
62fn modal_has_title(session: &Session) -> bool {
63 !modal_title_text(session).trim().is_empty()
64}
65
66fn resolve_modal_chrome_ansi_color(session: &Session) -> AnsiColorEnum {
67 session
68 .theme
69 .tool_accent
70 .or(session.theme.primary)
71 .or(session.theme.secondary)
72 .unwrap_or(AnsiColorEnum::Ansi256(Ansi256Color(
73 ui::SAFE_ANSI_BRIGHT_CYAN,
74 )))
75}
76
77fn modal_chrome_style(session: &Session) -> Style {
78 modal_heading_style(session)
79}
80
81fn render_modal_background(frame: &mut Frame<'_>, area: Rect, style: Style) {
82 if area.width == 0 || area.height == 0 {
83 return;
84 }
85
86 frame.render_widget(Clear, area);
87 frame.render_widget(Block::default().style(style), area);
88}
89
90fn render_modal_divider(frame: &mut Frame<'_>, area: Rect, style: Style) {
91 if area.width == 0 || area.height == 0 {
92 return;
93 }
94
95 frame.render_widget(
96 Paragraph::new(Line::from(Span::styled(
97 ui::INLINE_BLOCK_HORIZONTAL.repeat(area.width as usize),
98 style,
99 )))
100 .wrap(Wrap { trim: false }),
101 area,
102 );
103}
104
105fn wizard_step_has_inline_custom_editor(
106 wizard: &crate::ui::tui::session::modal::WizardModalState,
107) -> bool {
108 let Some(step) = wizard.steps.get(wizard.current_step) else {
109 return false;
110 };
111 let Some(selected_visible) = step.list.list_state.selected() else {
112 return false;
113 };
114 let Some(&item_index) = step.list.visible_indices.get(selected_visible) else {
115 return false;
116 };
117 let Some(item) = step.list.items.get(item_index) else {
118 return false;
119 };
120 matches!(
121 item.selection.as_ref(),
122 Some(InlineListSelection::RequestUserInputAnswer {
123 selected,
124 other,
125 ..
126 }) if selected.is_empty() && other.is_some()
127 )
128}
129
130pub fn split_inline_modal_area(session: &Session, area: Rect) -> (Rect, Option<Rect>) {
131 if area.width == 0 || area.height == 0 {
132 return (area, None);
133 }
134
135 let title_chrome_rows = if modal_has_title(session) {
136 MODAL_TITLE_CHROME_ROWS as u16
137 } else {
138 0
139 };
140 let multiline_list_present = if let Some(wizard) = session.wizard_overlay() {
141 wizard
142 .steps
143 .get(wizard.current_step)
144 .is_some_and(|step| list_has_two_line_items(&step.list))
145 } else if let Some(modal) = session.modal_state() {
146 modal.list.as_ref().is_some_and(list_has_two_line_items)
147 } else {
148 false
149 };
150
151 let desired_lines = if let Some(wizard) = session.wizard_overlay() {
152 let mut lines = 0usize;
153 lines = lines.saturating_add(1); if wizard.search.is_some() {
155 lines = lines.saturating_add(1);
156 }
157 lines = lines.saturating_add(2); if wizard.search.is_some() {
159 lines = lines.saturating_add(1); }
161 let (list_rows, summary_rows) = wizard
162 .steps
163 .get(wizard.current_step)
164 .map(|step| {
165 (
166 list_desired_rows(&step.list),
167 step.list.summary_line_rows(None),
168 )
169 })
170 .unwrap_or((1, 0));
171 lines = lines.saturating_add(list_rows);
172 lines = lines.saturating_add(summary_rows);
173 if wizard
174 .steps
175 .get(wizard.current_step)
176 .is_some_and(|step| step.notes_active || !step.notes.is_empty())
177 && !wizard_step_has_inline_custom_editor(wizard)
178 {
179 lines = lines.saturating_add(1);
180 }
181 lines = lines.saturating_add(
182 wizard
183 .instruction_lines()
184 .len()
185 .min(MAX_INLINE_INSTRUCTION_ROWS),
186 );
187 if title_chrome_rows > 0 {
188 lines = lines.saturating_add(1 + usize::from(title_chrome_rows)); }
190 lines
191 } else if let Some(modal) = session.modal_state() {
192 let mut lines = modal.lines.len().clamp(1, MAX_INLINE_INSTRUCTION_ROWS);
193 if modal.search.is_some() {
194 lines = lines.saturating_add(2);
195 }
196 if modal.secure_prompt.is_some() {
197 lines = lines.saturating_add(2);
198 }
199 if modal.list.is_some() && modal.search.is_some() {
200 lines = lines.saturating_add(1); }
202 if let Some(list) = modal.list.as_ref() {
203 lines = lines.saturating_add(list_desired_rows(list));
204 lines = lines.saturating_add(list.summary_line_rows(modal.footer_hint.as_deref()));
205 } else {
206 lines = lines.saturating_add(1);
207 }
208 if title_chrome_rows > 0 {
209 lines = lines.saturating_add(1 + usize::from(title_chrome_rows)); }
211 lines
212 } else {
213 return (area, None);
214 };
215
216 let max_panel_height = area.height.saturating_sub(1);
217 if max_panel_height == 0 {
218 return (area, None);
219 }
220
221 let min_height = ui::MODAL_MIN_HEIGHT.min(max_panel_height).max(1);
222 let modal_height_cap = if multiline_list_present {
223 MAX_INLINE_MODAL_HEIGHT_MULTILINE
224 } else {
225 MAX_INLINE_MODAL_HEIGHT
226 }
227 .saturating_add(title_chrome_rows);
228 let capped_max = modal_height_cap.min(max_panel_height).max(min_height);
229 let desired_height = (desired_lines.min(u16::MAX as usize) as u16)
230 .max(min_height)
231 .min(capped_max);
232
233 let chunks =
234 Layout::vertical([Constraint::Min(1), Constraint::Length(desired_height)]).split(area);
235 (chunks[0], Some(chunks[1]))
236}
237
238pub(crate) fn floating_modal_area(area: Rect) -> Rect {
239 if area.width == 0 || area.height == 0 {
240 return area;
241 }
242
243 let height = (area.height / 2).max(1);
244 let y = area.y.saturating_add(area.height.saturating_sub(height));
245 Rect::new(area.x, y, area.width, height)
246}
247
248pub fn render_modal(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
249 if area.width == 0 || area.height == 0 {
250 session.set_modal_list_area(None);
251 session.set_modal_text_areas(Vec::new());
252 session.set_modal_link_targets(Vec::new());
253 return;
254 }
255
256 if session.skip_confirmations
258 && let Some(mut modal) = session.take_modal_state()
259 {
260 if let Some(list) = &mut modal.list
261 && let Some(_selection) = list.current_selection()
262 {
263 }
268 session.input_enabled = modal.restore_input;
269 session.cursor_visible = modal.restore_cursor;
270 session.needs_full_clear = true;
271 session.needs_redraw = true;
272 session.set_modal_list_area(None);
273 session.set_modal_text_areas(Vec::new());
274 session.set_modal_link_targets(Vec::new());
275 return;
276 }
277
278 let styles = modal_render_styles(session);
279 let input_styles = input_styles_from_theme(&session.theme);
280 render_modal_background(frame, area, styles.selectable);
281 let link_style = session
282 .styles
283 .transcript_link_style()
284 .add_modifier(Modifier::UNDERLINED);
285 let hovered_link_style = link_style.add_modifier(Modifier::BOLD);
286 let workspace_root = session.workspace_root.clone();
287 let last_mouse_position = session.last_mouse_position;
288 let title = modal_title_text(session).trim().to_owned();
289 let mut title_link_targets = Vec::new();
290 let (body_area, title_area) = if title.is_empty() {
291 (area, None)
292 } else {
293 let chunks = Layout::vertical([
294 Constraint::Length(1),
295 Constraint::Length(1),
296 Constraint::Min(0),
297 Constraint::Length(1),
298 ])
299 .split(area);
300 let title_area = chunks[0];
301 let top_divider_area = chunks[1];
302 let body_area = chunks[2];
303 let bottom_divider_area = chunks[3];
304 let title_line = Line::from(Span::styled(title, styles.title));
305 let (decorated_title, link_targets) = decorate_detected_link_lines(
306 vec![title_line],
307 title_area,
308 workspace_root.as_deref(),
309 last_mouse_position,
310 link_style,
311 hovered_link_style,
312 );
313 title_link_targets = link_targets;
314 render_modal_background(frame, title_area, styles.selectable);
315 frame.render_widget(
316 Paragraph::new(decorated_title)
317 .style(styles.title)
318 .wrap(Wrap { trim: true }),
319 title_area,
320 );
321 render_modal_divider(frame, top_divider_area, styles.border);
322 render_modal_divider(frame, bottom_divider_area, styles.border);
323 (body_area, Some(title_area))
324 };
325
326 if let Some(wizard) = session.wizard_overlay_mut() {
327 render_modal_background(frame, body_area, styles.selectable);
328 if body_area.width == 0 || body_area.height == 0 {
329 session.set_modal_list_area(None);
330 session.set_modal_text_areas(Vec::new());
331 session.set_modal_link_targets(Vec::new());
332 return;
333 }
334 let mut outcome = render_wizard_modal_body(
335 frame,
336 body_area,
337 wizard,
338 &styles,
339 &input_styles,
340 workspace_root.as_deref(),
341 last_mouse_position,
342 link_style,
343 hovered_link_style,
344 );
345 if let Some(title_area) = title_area {
346 outcome.text_areas.push(title_area);
347 outcome.link_targets.extend(title_link_targets.clone());
348 }
349 session.set_modal_list_area(outcome.list_area);
350 session.set_modal_text_areas(outcome.text_areas);
351 session.set_modal_link_targets(outcome.link_targets);
352 return;
353 }
354
355 let input = session.input_manager.content().to_owned();
356 let cursor = session.input_manager.cursor();
357 let Some(modal) = session.modal_state_mut() else {
358 session.set_modal_list_area(None);
359 session.set_modal_text_areas(Vec::new());
360 session.set_modal_link_targets(Vec::new());
361 return;
362 };
363
364 render_modal_background(frame, body_area, styles.selectable);
365 if body_area.width == 0 || body_area.height == 0 {
366 session.set_modal_list_area(None);
367 session.set_modal_text_areas(Vec::new());
368 session.set_modal_link_targets(Vec::new());
369 return;
370 }
371
372 if modal.is_help_modal {
373 use ratatui_cheese::help::{Binding, Help, HelpStyles};
374 let help = Help::default()
375 .show_all(true)
376 .styles(HelpStyles::from_palette(
377 &ratatui_cheese::theme::Palette::dark(),
378 ))
379 .bindings(vec![
380 Binding::new("?", "help"),
381 Binding::new("Enter", "submit"),
382 Binding::new("Ctrl+C", "interrupt"),
383 Binding::new("Esc", "clear/cancel"),
384 Binding::new("Tab", "accept/queue"),
385 Binding::new("Shift+Tab", "mode picker"),
386 ])
387 .binding_groups(vec![
388 vec![
389 Binding::new("!cmd", "shell mode"),
390 Binding::new("@path", "file reference"),
391 Binding::new("Enter", "submit/queue"),
392 Binding::new("Ctrl+Enter", "run/steer"),
393 Binding::new("Shift+Enter", "new line"),
394 Binding::new("Esc", "clear/cancel"),
395 Binding::new("Ctrl+C", "interrupt/copy"),
396 Binding::new("Ctrl+D", "exit"),
397 Binding::new("PgUp/PgDn", "scroll"),
398 Binding::new("Alt+S", "subprocesses"),
399 ],
400 vec![
401 Binding::new("Ctrl+A/E", "line ends"),
402 Binding::new("Ctrl+F/B", "char move"),
403 Binding::new("Alt+F/B", "word move"),
404 Binding::new("Alt+←/→", "word move"),
405 Binding::new("Ctrl+P/N", "history"),
406 Binding::new("Ctrl+R/S", "history search"),
407 Binding::new("Ctrl+W", "delete prev word"),
408 Binding::new("Alt+D", "delete next word"),
409 Binding::new("Ctrl+U/K", "delete to edge"),
410 Binding::new("Ctrl+T", "transpose"),
411 Binding::new("Alt+U/L/C", "case change"),
412 ],
413 vec![
414 Binding::new("/", "commands"),
415 Binding::new("?", "shortcuts"),
416 Binding::new("Shift+Tab", "mode picker"),
417 Binding::new("Tab", "accept/queue"),
418 Binding::new("Ctrl+L", "clear screen"),
419 Binding::new("Ctrl+M", "model picker"),
420 Binding::new("Ctrl+O", "copy response"),
421 Binding::new("Alt+P", "prompt suggest"),
422 Binding::new("Alt+O", "transcript review"),
423 Binding::new("Ctrl+I", "lists"),
424 Binding::new("Ctrl+G", "editor"),
425 Binding::new("Ctrl+Z/Y", "undo/redo"),
426 ],
427 ]);
428 frame.render_widget(&help, body_area);
429 session.set_modal_list_area(None);
430 session.set_modal_text_areas(Vec::new());
431 session.set_modal_link_targets(Vec::new());
432 return;
433 }
434 let mut outcome = render_modal_body(
435 frame,
436 body_area,
437 ModalBodyContext {
438 instructions: &modal.lines,
439 footer_hint: modal.footer_hint.as_deref(),
440 list: modal.list.as_mut(),
441 styles: &styles,
442 secure_prompt: modal.secure_prompt.as_ref(),
443 search: modal.search.as_ref(),
444 input: &input,
445 cursor,
446 input_styles: &input_styles,
447 },
448 workspace_root.as_deref(),
449 last_mouse_position,
450 link_style,
451 hovered_link_style,
452 );
453 if let Some(title_area) = title_area {
454 outcome.text_areas.push(title_area);
455 outcome.link_targets.extend(title_link_targets);
456 }
457 session.set_modal_list_area(outcome.list_area);
458 session.set_modal_text_areas(outcome.text_areas);
459 session.set_modal_link_targets(outcome.link_targets);
460}
461
462pub(crate) fn modal_render_styles(session: &Session) -> ModalRenderStyles {
463 let default_style = modal_base_style(session);
464 let header_style = modal_heading_style(session);
465 let chrome_style = modal_chrome_style(session);
466 let chrome_border_style = session
467 .styles
468 .border_style()
469 .fg(ratatui_color_from_ansi(resolve_modal_chrome_ansi_color(
470 session,
471 )))
472 .remove_modifier(Modifier::DIM)
473 .add_modifier(Modifier::BOLD);
474 ModalRenderStyles {
475 border: chrome_border_style,
476 highlight: modal_list_highlight_style(session),
477 badge: default_style.add_modifier(Modifier::DIM | Modifier::BOLD),
478 header: header_style,
479 selectable: default_style.add_modifier(Modifier::DIM),
480 detail: default_style.add_modifier(Modifier::DIM),
481 search_match: header_style.add_modifier(Modifier::UNDERLINED),
482 title: chrome_style,
483 divider: default_style.add_modifier(Modifier::DIM),
484 instruction_border: chrome_border_style,
485 instruction_title: header_style,
486 instruction_bullet: header_style,
487 instruction_body: default_style,
488 hint: default_style.add_modifier(Modifier::DIM | Modifier::ITALIC),
489 }
490}
491
492#[expect(dead_code)]
493pub(super) fn handle_tool_code_fence_marker(session: &mut Session, text: &str) -> bool {
494 let trimmed = text.trim();
495 let stripped = trimmed
496 .strip_prefix("```")
497 .or_else(|| trimmed.strip_prefix("~~~"));
498
499 let Some(rest) = stripped else {
500 return false;
501 };
502
503 if rest.contains("```") || rest.contains("~~~") {
504 return false;
505 }
506
507 if session.in_tool_code_fence {
508 session.in_tool_code_fence = false;
509 remove_trailing_empty_tool_line(session);
510 } else {
511 session.in_tool_code_fence = true;
512 }
513
514 true
515}
516
517#[expect(dead_code)]
518fn remove_trailing_empty_tool_line(session: &mut Session) {
519 let should_remove = session
520 .lines
521 .last()
522 .map(|line| line.kind == InlineMessageKind::Tool && line.segments.is_empty())
523 .unwrap_or(false);
524 if should_remove {
525 session.lines.pop();
526 session.invalidate_scroll_metrics();
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::ui::tui::InlineTheme;
534 use ratatui::style::Color;
535
536 #[test]
537 fn modal_title_text_uses_modal_title_and_empty_default() {
538 let mut session = Session::new(InlineTheme::default(), None, 20);
539 assert_eq!(modal_title_text(&session), "");
540
541 session.show_modal("Config".to_owned(), vec![], None);
542 assert_eq!(modal_title_text(&session), "Config");
543 }
544
545 #[test]
546 fn modal_title_style_uses_explicit_chrome_color() {
547 let session = Session::new(InlineTheme::default(), None, 20);
548 let styles = modal_render_styles(&session);
549
550 assert_eq!(
551 styles.title.fg,
552 Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
553 );
554 assert!(styles.title.bg.is_none());
555 assert_eq!(
556 styles.border.fg,
557 Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
558 );
559 assert!(styles.title.add_modifier.contains(Modifier::BOLD));
560 }
561
562 #[test]
563 fn modal_section_headers_use_chrome_color_on_base_background() {
564 let theme = InlineTheme {
565 foreground: Some(AnsiColorEnum::Ansi256(Ansi256Color(16))),
566 background: Some(AnsiColorEnum::Ansi256(Ansi256Color(231))),
567 primary: Some(AnsiColorEnum::Ansi256(Ansi256Color(117))),
568 ..InlineTheme::default()
569 };
570 let session = Session::new(theme, None, 20);
571 let styles = modal_render_styles(&session);
572
573 assert_eq!(styles.header.fg, Some(Color::Indexed(117)));
574 assert_eq!(styles.header.bg, Some(Color::Indexed(231)));
575 assert_eq!(styles.instruction_title.fg, Some(Color::Indexed(117)));
576 assert_eq!(styles.instruction_title.bg, Some(Color::Indexed(231)));
577 assert!(styles.header.add_modifier.contains(Modifier::BOLD));
578 }
579
580 #[test]
581 fn floating_modal_area_uses_bottom_half_of_viewport() {
582 let area = floating_modal_area(Rect::new(3, 5, 80, 31));
583
584 assert_eq!(area, Rect::new(3, 21, 80, 15));
585 }
586
587 #[test]
588 fn floating_modal_area_uses_exact_half_for_even_height() {
589 let area = floating_modal_area(Rect::new(0, 0, 80, 30));
590
591 assert_eq!(area, Rect::new(0, 15, 80, 15));
592 }
593
594 #[test]
595 fn floating_modal_area_preserves_single_row_viewport() {
596 let area = floating_modal_area(Rect::new(0, 0, 80, 1));
597
598 assert_eq!(area, Rect::new(0, 0, 80, 1));
599 }
600}