1use crate::config::constants::ui;
2use crate::ui::markdown::render_markdown;
3use crate::ui::tui::session::inline_list::{InlineListRow, render_inline_list, row_height};
4use crate::ui::tui::types::{InlineListSelection, SecurePromptConfig};
5use ratatui::{
6 prelude::*,
7 widgets::{Paragraph, Tabs, Wrap},
8};
9use unicode_width::UnicodeWidthStr;
10
11use super::layout::{ModalBodyContext, ModalRenderStyles, ModalSection};
12use super::state::{ModalListState, ModalSearchState, WizardModalState, WizardStepState};
13use crate::ui::tui::session::wrapping;
14use std::mem;
15
16fn markdown_to_plain_lines(text: &str) -> Vec<String> {
17 let mut lines = render_markdown(text)
18 .into_iter()
19 .map(|line| {
20 line.segments
21 .into_iter()
22 .map(|segment| segment.text)
23 .collect::<String>()
24 })
25 .collect::<Vec<_>>();
26
27 while lines.last().is_some_and(|line| line.trim().is_empty()) {
28 lines.pop();
29 }
30
31 if lines.is_empty() {
32 vec![String::new()]
33 } else {
34 lines
35 }
36}
37
38fn wrap_line_to_width(line: &str, width: usize) -> Vec<String> {
39 if width == 0 {
40 return vec![line.to_owned()];
41 }
42
43 if line.is_empty() {
44 return vec![String::new()];
45 }
46
47 let mut rows = Vec::new();
48 let mut current = String::new();
49 let mut current_width = 0usize;
50
51 for ch in line.chars() {
52 let ch_width = unicode_width::UnicodeWidthChar::width(ch)
53 .unwrap_or(0)
54 .max(1);
55 if current_width + ch_width > width && !current.is_empty() {
56 rows.push(mem::take(&mut current));
57 current_width = 0;
58 if ch.is_whitespace() {
59 continue;
60 }
61 }
62
63 current.push(ch);
64 current_width += ch_width;
65 }
66
67 if !current.is_empty() {
68 rows.push(current);
69 }
70
71 if rows.is_empty() {
72 vec![String::new()]
73 } else {
74 rows
75 }
76}
77
78fn render_markdown_lines_for_modal(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
79 let mut lines = Vec::new();
80 for line in markdown_to_plain_lines(text) {
81 let line_ratatui = Line::from(Span::styled(line, style));
82 let wrapped = wrapping::wrap_line_preserving_urls(line_ratatui, width);
83 lines.extend(wrapped);
84 }
85
86 if lines.is_empty() {
87 vec![Line::default()]
88 } else {
89 lines
90 }
91}
92
93pub fn render_modal_list(
94 frame: &mut Frame<'_>,
95 area: Rect,
96 list: &mut ModalListState,
97 styles: &ModalRenderStyles,
98 footer_hint: Option<&str>,
99) {
100 if area.width == 0 || area.height == 0 {
101 return;
102 }
103
104 let summary = modal_list_summary_line(list, styles, footer_hint);
105 let (list_area, summary_area) = if summary.is_some() && area.height > 1 {
106 let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
107 (chunks[0], Some(chunks[1]))
108 } else {
109 (area, None)
110 };
111
112 if let (Some(line), Some(summary_area)) = (summary, summary_area) {
113 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), summary_area);
114 }
115
116 if list.visible_indices.is_empty() {
117 list.list_state.select(None);
118 *list.list_state.offset_mut() = 0;
119 let message = Paragraph::new(Line::from(Span::styled(
120 ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
121 styles.detail,
122 )))
123 .wrap(Wrap { trim: true });
124 frame.render_widget(message, list_area);
125 return;
126 }
127
128 let viewport_rows = list_area.height;
129 list.set_viewport_rows(viewport_rows);
130 list.ensure_visible(viewport_rows);
131 let selection_gutter = ui::MODAL_LIST_HIGHLIGHT_FULL.chars().count() as u16;
132 let content_width = list_area.width.saturating_sub(selection_gutter) as usize;
133 let rendered_items = list
134 .visible_indices
135 .iter()
136 .enumerate()
137 .map(|(visible_index, &item_index)| {
138 let lines =
139 modal_list_item_lines(list, visible_index, item_index, styles, content_width);
140 (
141 InlineListRow {
142 lines: lines.clone(),
143 style: styles.selectable,
144 },
145 row_height(&lines),
146 )
147 })
148 .collect::<Vec<_>>();
149
150 let item_count = rendered_items.len();
151 let selected = list
152 .list_state
153 .selected()
154 .filter(|index| *index < item_count);
155
156 let widget_state = render_inline_list(
157 frame,
158 list_area,
159 rendered_items,
160 selected,
161 Some(styles.highlight),
162 );
163
164 list.list_state.select(widget_state.selected);
165 *list.list_state.offset_mut() = widget_state.scroll_offset_index();
166}
167
168#[allow(dead_code)]
170pub fn render_wizard_tabs(
171 frame: &mut Frame<'_>,
172 area: Rect,
173 steps: &[WizardStepState],
174 current_step: usize,
175 styles: &ModalRenderStyles,
176) {
177 if area.height == 0 || area.width == 0 {
178 return;
179 }
180
181 let titles: Vec<Line<'static>> = steps
182 .iter()
183 .enumerate()
184 .map(|(i, step)| {
185 let icon = if step.completed { "✔" } else { "☐" };
186 let text = format!("{} {}", icon, step.title);
187 if i == current_step {
188 Line::from(text).style(styles.highlight)
189 } else if step.completed {
190 Line::from(text).style(styles.selectable)
191 } else {
192 Line::from(text).style(styles.detail)
193 }
194 })
195 .collect();
196
197 let tabs = Tabs::new(titles)
198 .select(Some(current_step))
199 .divider(" ")
200 .padding("← ", " →")
201 .highlight_style(styles.highlight);
202
203 frame.render_widget(tabs, area);
204}
205
206#[allow(dead_code)]
208pub fn render_wizard_modal_body(
209 frame: &mut Frame<'_>,
210 area: Rect,
211 wizard: &mut WizardModalState,
212 styles: &ModalRenderStyles,
213) {
214 if area.width == 0 || area.height == 0 {
215 return;
216 }
217
218 let is_multistep = wizard.mode == crate::ui::tui::types::WizardModalMode::MultiStep;
219 let current_step_state = wizard.steps.get(wizard.current_step);
220 let has_notes = current_step_state.is_some_and(|s| s.notes_active || !s.notes.is_empty());
221 let instruction_lines = wizard.instruction_lines();
222 let content_width = area.width.max(1) as usize;
223 let header_lines = if is_multistep {
224 render_markdown_lines_for_modal(
225 wizard.question_header().as_str(),
226 content_width,
227 styles.header,
228 )
229 } else {
230 Vec::new()
231 };
232 let question_lines = wizard
233 .steps
234 .get(wizard.current_step)
235 .map(|step| {
236 render_markdown_lines_for_modal(step.question.as_str(), content_width, styles.header)
237 })
238 .unwrap_or_else(|| vec![Line::default()]);
239
240 let mut constraints = Vec::new();
242 if is_multistep {
243 constraints.push(Constraint::Length(
244 header_lines.len().min(u16::MAX as usize) as u16,
245 ));
246 } else {
247 constraints.push(Constraint::Length(1));
248 }
249 if wizard.search.is_some() {
250 constraints.push(Constraint::Length(3));
251 }
252 constraints.push(Constraint::Length(
253 question_lines.len().max(1).min(u16::MAX as usize) as u16,
254 ));
255 constraints.push(Constraint::Min(3));
256 if has_notes {
257 constraints.push(Constraint::Length(1));
258 }
259 if !instruction_lines.is_empty() {
260 constraints.push(Constraint::Length(
261 instruction_lines.len().min(u16::MAX as usize) as u16,
262 ));
263 }
264
265 let chunks = Layout::vertical(constraints).split(area);
266
267 let mut idx = 0;
268 if is_multistep {
269 let header = Paragraph::new(header_lines).wrap(Wrap { trim: false });
270 frame.render_widget(header, chunks[idx]);
271 } else {
272 render_wizard_tabs(
273 frame,
274 chunks[idx],
275 &wizard.steps,
276 wizard.current_step,
277 styles,
278 );
279 }
280 idx += 1;
281
282 if let Some(search) = wizard.search.as_ref() {
283 render_modal_search(frame, chunks[idx], search, styles);
284 idx += 1;
285 }
286
287 let question = Paragraph::new(question_lines).wrap(Wrap { trim: false });
288 frame.render_widget(question, chunks[idx]);
289 idx += 1;
290
291 if let Some(step) = wizard.steps.get_mut(wizard.current_step) {
292 render_modal_list(frame, chunks[idx], &mut step.list, styles, None);
293 }
294 idx += 1;
295
296 if let Some(step) = wizard.steps.get(wizard.current_step)
297 && (step.notes_active || !step.notes.is_empty())
298 {
299 let label_text = step.freeform_label.as_deref().unwrap_or("›");
300 let mut spans = vec![Span::styled(format!("{} ", label_text), styles.header)];
301
302 if step.notes.is_empty() {
303 if let Some(placeholder) = step.freeform_placeholder.as_ref() {
304 spans.push(Span::styled(placeholder.clone(), styles.detail));
305 }
306 } else {
307 spans.push(Span::styled(step.notes.clone(), styles.selectable));
308 }
309
310 if step.notes_active {
311 spans.push(Span::styled("▌", styles.highlight));
312 }
313
314 let notes = Paragraph::new(Line::from(spans));
315 frame.render_widget(notes, chunks[idx]);
316 idx += 1;
317 }
318
319 if !instruction_lines.is_empty() && idx < chunks.len() {
320 let lines = instruction_lines
321 .into_iter()
322 .map(|line| Line::from(Span::styled(line, styles.hint)))
323 .collect::<Vec<_>>();
324 let instructions = Paragraph::new(lines);
325 frame.render_widget(instructions, chunks[idx]);
326 }
327}
328
329#[allow(clippy::const_is_empty)]
330fn modal_list_summary_line(
331 list: &ModalListState,
332 styles: &ModalRenderStyles,
333 footer_hint: Option<&str>,
334) -> Option<Line<'static>> {
335 if !list.filter_active() {
336 if list
337 .items
338 .iter()
339 .any(|item| matches!(item.selection, Some(InlineListSelection::ConfigAction(_))))
340 {
341 return Some(Line::from(Span::styled(
342 "Navigation: ↑/↓ select • Space/Enter apply • ←/→ change value • Esc close",
343 styles.hint,
344 )));
345 }
346 let density = if list.compact_rows() {
347 "Density: Compact"
348 } else {
349 "Density: Comfortable"
350 };
351 let message = match footer_hint {
352 Some(hint) if !hint.is_empty() => format!("{} • Alt+D {}", hint, density),
353 _ => format!("Alt+D {}", density),
354 };
355 return Some(Line::from(Span::styled(message, styles.hint)));
356 }
357
358 let mut spans = Vec::new();
359 if let Some(query) = list.filter_query().filter(|value| !value.is_empty()) {
360 spans.push(Span::styled(
361 format!("{}:", ui::MODAL_LIST_SUMMARY_FILTER_LABEL),
362 styles.detail,
363 ));
364 spans.push(Span::raw(" "));
365 spans.push(Span::styled(query.to_owned(), styles.selectable));
366 }
367
368 let matches = list.visible_selectable_count();
369 let total = list.total_selectable();
370 if matches == 0 {
371 if !spans.is_empty() {
372 spans.push(Span::styled(
373 ui::MODAL_LIST_SUMMARY_SEPARATOR.to_owned(),
374 styles.detail,
375 ));
376 }
377 spans.push(Span::styled(
378 ui::MODAL_LIST_SUMMARY_NO_MATCHES.to_owned(),
379 styles.search_match,
380 ));
381 if !ui::MODAL_LIST_SUMMARY_RESET_HINT.is_empty() {
382 spans.push(Span::styled(
383 format!(
384 "{}{}",
385 ui::MODAL_LIST_SUMMARY_SEPARATOR,
386 ui::MODAL_LIST_SUMMARY_RESET_HINT
387 ),
388 styles.hint,
389 ));
390 }
391 } else {
392 if !spans.is_empty() {
393 spans.push(Span::styled(
394 ui::MODAL_LIST_SUMMARY_SEPARATOR.to_owned(),
395 styles.detail,
396 ));
397 }
398 spans.push(Span::styled(
399 format!(
400 "{} {} {} {}",
401 ui::MODAL_LIST_SUMMARY_MATCHES_LABEL,
402 matches,
403 ui::MODAL_LIST_SUMMARY_TOTAL_LABEL,
404 total
405 ),
406 styles.detail,
407 ));
408 }
409
410 if spans.is_empty() {
411 None
412 } else {
413 Some(Line::from(spans))
414 }
415}
416
417pub fn render_modal_body(frame: &mut Frame<'_>, area: Rect, context: ModalBodyContext<'_, '_>) {
418 if area.width == 0 || area.height == 0 {
419 return;
420 }
421
422 let mut sections = Vec::new();
423 let has_instructions = context
424 .instructions
425 .iter()
426 .any(|line| !line.trim().is_empty());
427 if context.search.is_some() {
428 sections.push(ModalSection::Search);
429 }
430 if has_instructions {
431 sections.push(ModalSection::Instructions);
432 }
433 if context.secure_prompt.is_some() {
434 sections.push(ModalSection::Prompt);
435 }
436 if context.list.is_some() {
437 sections.push(ModalSection::List);
438 }
439
440 if sections.is_empty() {
441 return;
442 }
443
444 let mut constraints = Vec::new();
445 for section in §ions {
446 match section {
447 ModalSection::Search => constraints.push(Constraint::Length(1.min(area.height))),
448 ModalSection::Instructions => {
449 let visible_rows = context.instructions.len().clamp(1, 6) as u16;
450 let instruction_title_rows = if ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
451 0
452 } else {
453 1
454 };
455 let height = visible_rows.saturating_add(instruction_title_rows);
456 constraints.push(Constraint::Length(height.min(area.height)));
457 }
458 ModalSection::Prompt => constraints.push(Constraint::Length(2.min(area.height))),
459 ModalSection::List => constraints.push(Constraint::Min(1)),
460 }
461 }
462
463 let chunks = Layout::vertical(constraints).split(area);
464 let mut list_state = context.list;
465
466 for (section, chunk) in sections.into_iter().zip(chunks.iter()) {
467 match section {
468 ModalSection::Instructions => {
469 if chunk.height > 0 && has_instructions {
470 render_modal_instructions(frame, *chunk, context.instructions, context.styles);
471 }
472 }
473 ModalSection::Prompt => {
474 if let Some(config) = context.secure_prompt {
475 render_secure_prompt(frame, *chunk, config, context.input, context.cursor);
476 }
477 }
478 ModalSection::Search => {
479 if let Some(config) = context.search {
480 render_modal_search(frame, *chunk, config, context.styles);
481 }
482 }
483 ModalSection::List => {
484 if let Some(list_state) = list_state.as_deref_mut() {
485 render_modal_list(
486 frame,
487 *chunk,
488 list_state,
489 context.styles,
490 context.footer_hint,
491 );
492 }
493 }
494 }
495 }
496}
497
498fn render_modal_instructions(
499 frame: &mut Frame<'_>,
500 area: Rect,
501 instructions: &[String],
502 styles: &ModalRenderStyles,
503) {
504 fn wrap_instruction_lines(text: &str, width: usize) -> Vec<String> {
505 if width == 0 {
506 return vec![text.to_owned()];
507 }
508
509 let mut lines = Vec::new();
510 let mut current = String::new();
511
512 for word in text.split_whitespace() {
513 let word_width = UnicodeWidthStr::width(word);
514 if current.is_empty() {
515 current.push_str(word);
516 continue;
517 }
518
519 let current_width = UnicodeWidthStr::width(current.as_str());
520 let candidate_width = current_width.saturating_add(1).saturating_add(word_width);
521 if candidate_width > width {
522 lines.push(current);
523 current = word.to_owned();
524 } else {
525 current.push(' ');
526 current.push_str(word);
527 }
528 }
529
530 if !current.is_empty() {
531 lines.push(current);
532 }
533
534 if lines.is_empty() {
535 vec![text.to_owned()]
536 } else {
537 lines
538 }
539 }
540
541 if area.width == 0 || area.height == 0 {
542 return;
543 }
544
545 let mut items: Vec<Vec<Line<'static>>> = Vec::new();
546 let mut first_content_rendered = false;
547 let content_width = area.width.saturating_sub(2) as usize;
548 let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
549 let bullet_indent = " ".repeat(UnicodeWidthStr::width(bullet_prefix.as_str()));
550
551 for line in instructions {
552 let trimmed = line.trim();
553 if trimmed.is_empty() {
554 items.push(vec![Line::default()]);
555 continue;
556 }
557
558 let wrapped = wrap_instruction_lines(trimmed, content_width);
559 if wrapped.is_empty() {
560 items.push(vec![Line::default()]);
561 continue;
562 }
563
564 if !first_content_rendered {
565 let mut lines = Vec::new();
566 for (index, segment) in wrapped.into_iter().enumerate() {
567 let style = if index == 0 {
568 styles.header
569 } else {
570 styles.instruction_body
571 };
572 lines.push(Line::from(Span::styled(segment, style)));
573 }
574 items.push(lines);
575 first_content_rendered = true;
576 } else {
577 let mut lines = Vec::new();
578 for (index, segment) in wrapped.into_iter().enumerate() {
579 if index == 0 {
580 lines.push(Line::from(vec![
581 Span::styled(bullet_prefix.clone(), styles.instruction_bullet),
582 Span::styled(segment, styles.instruction_body),
583 ]));
584 } else {
585 lines.push(Line::from(vec![
586 Span::styled(bullet_indent.clone(), styles.instruction_bullet),
587 Span::styled(segment, styles.instruction_body),
588 ]));
589 }
590 }
591 items.push(lines);
592 }
593 }
594
595 if items.is_empty() {
596 items.push(vec![Line::default()]);
597 }
598
599 let mut rendered_items = Vec::new();
600 if !ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
601 rendered_items.push((
602 InlineListRow::single(
603 Line::from(Span::styled(
604 ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
605 styles.instruction_title,
606 )),
607 styles.instruction_title,
608 ),
609 1_u16,
610 ));
611 }
612
613 rendered_items.extend(items.into_iter().map(|lines| {
614 (
615 InlineListRow {
616 lines: lines.clone(),
617 style: styles.instruction_body,
618 },
619 row_height(&lines),
620 )
621 }));
622
623 let _ = render_inline_list(frame, area, rendered_items, None, None);
624}
625
626fn render_modal_search(
627 frame: &mut Frame<'_>,
628 area: Rect,
629 search: &ModalSearchState,
630 styles: &ModalRenderStyles,
631) {
632 if area.width == 0 || area.height == 0 {
633 return;
634 }
635
636 let mut line = vec![Span::styled(format!("{}: ", search.label), styles.header)];
637 if search.query.is_empty() {
638 if let Some(placeholder) = &search.placeholder {
639 line.push(Span::styled(
640 placeholder.clone(),
641 styles.detail.add_modifier(Modifier::ITALIC),
642 ));
643 }
644 } else {
645 line.push(Span::styled(search.query.clone(), styles.selectable));
646 line.push(Span::styled(" • Esc clears", styles.hint));
647 }
648 line.push(Span::styled("▌".to_owned(), styles.highlight));
649 let paragraph = Paragraph::new(Line::from(line)).wrap(Wrap { trim: true });
650 frame.render_widget(paragraph, area);
651}
652
653fn render_secure_prompt(
654 frame: &mut Frame<'_>,
655 area: Rect,
656 config: &SecurePromptConfig,
657 input: &str,
658 _cursor: usize,
659) {
660 if area.width == 0 || area.height == 0 {
661 return;
662 }
663
664 let display_text = if input.is_empty() {
665 config.placeholder.clone().unwrap_or_default()
666 } else if config.mask_input {
667 let grapheme_count = input.chars().count();
668 std::iter::repeat_n('•', grapheme_count).collect()
669 } else {
670 input.to_owned()
671 };
672
673 let label_paragraph = Paragraph::new(config.label.clone());
675 let label_area = Rect {
676 x: area.x,
677 y: area.y,
678 width: area.width,
679 height: 1.min(area.height),
680 };
681 frame.render_widget(label_paragraph, label_area);
682
683 if area.height > 1 {
685 let input_area = Rect {
686 x: area.x,
687 y: area.y + 1,
688 width: area.width,
689 height: (area.height - 1).max(1),
690 };
691
692 let input_paragraph = Paragraph::new(display_text);
693 frame.render_widget(input_paragraph, input_area);
694 }
695}
696
697pub(super) fn highlight_segments(
698 text: &str,
699 normal_style: Style,
700 highlight_style: Style,
701 terms: &[String],
702) -> Vec<Span<'static>> {
703 if text.is_empty() {
704 return vec![Span::styled(String::new(), normal_style)];
705 }
706
707 if terms.is_empty() {
708 return vec![Span::styled(text.to_owned(), normal_style)];
709 }
710
711 let lower = text.to_ascii_lowercase();
712 let mut char_offsets: Vec<usize> = text.char_indices().map(|(offset, _)| offset).collect();
713 char_offsets.push(text.len());
714 let char_count = char_offsets.len().saturating_sub(1);
715 if char_count == 0 {
716 return vec![Span::styled(text.to_owned(), normal_style)];
717 }
718
719 let mut highlight_flags = vec![false; char_count];
720 for term in terms {
721 let needle = term.as_str();
722 if needle.is_empty() {
723 continue;
724 }
725
726 let mut search_start = 0usize;
727 while search_start < lower.len() {
728 let Some(pos) = lower[search_start..].find(needle) else {
729 break;
730 };
731 let byte_start = search_start + pos;
732 let byte_end = byte_start + needle.len();
733 let start_index = char_offsets.partition_point(|offset| *offset < byte_start);
734 let end_index = char_offsets.partition_point(|offset| *offset < byte_end);
735 for flag in highlight_flags
736 .iter_mut()
737 .take(end_index.min(char_count))
738 .skip(start_index)
739 {
740 *flag = true;
741 }
742 search_start = byte_end;
743 }
744 }
745
746 let mut segments = Vec::new();
747 let mut current = String::new();
748 let mut current_highlight = highlight_flags.first().copied().unwrap_or(false);
749 for (idx, ch) in text.chars().enumerate() {
750 let highlight = highlight_flags.get(idx).copied().unwrap_or(false);
751 if idx == 0 {
752 current_highlight = highlight;
753 } else if highlight != current_highlight {
754 let style = if current_highlight {
755 highlight_style
756 } else {
757 normal_style
758 };
759 segments.push(Span::styled(mem::take(&mut current), style));
760 current_highlight = highlight;
761 }
762 current.push(ch);
763 }
764
765 if !current.is_empty() {
766 let style = if current_highlight {
767 highlight_style
768 } else {
769 normal_style
770 };
771 segments.push(Span::styled(current, style));
772 }
773
774 if segments.is_empty() {
775 segments.push(Span::styled(String::new(), normal_style));
776 }
777
778 segments
779}
780
781pub fn modal_list_item_lines(
782 list: &ModalListState,
783 _visible_index: usize,
784 item_index: usize,
785 styles: &ModalRenderStyles,
786 content_width: usize,
787) -> Vec<Line<'static>> {
788 let item = match list.items.get(item_index) {
789 Some(i) => i,
790 None => {
791 tracing::warn!("modal list item index {item_index} out of bounds");
792 return vec![Line::default()];
793 }
794 };
795 if item.is_divider {
796 let divider = if item.title.is_empty() {
797 ui::INLINE_BLOCK_HORIZONTAL.repeat(8)
798 } else {
799 item.title.clone()
800 };
801 return vec![Line::from(Span::styled(divider, styles.divider))];
802 }
803
804 let indent = " ".repeat(item.indent as usize);
805 let selection_padding = " ".repeat(ui::MODAL_LIST_HIGHLIGHT_FULL.chars().count());
806
807 let mut primary_spans = Vec::new();
808 if !selection_padding.is_empty() {
809 primary_spans.push(Span::raw(selection_padding.clone()));
810 }
811
812 if !indent.is_empty() {
813 primary_spans.push(Span::raw(indent.clone()));
814 }
815
816 if let Some(badge) = &item.badge {
817 let badge_label = format!("[{}]", badge);
818 primary_spans.push(Span::styled(
819 badge_label,
820 modal_badge_style(badge.as_str(), styles),
821 ));
822 primary_spans.push(Span::raw(" "));
823 }
824
825 let title_style = if item.selection.is_some() {
826 styles.selectable
827 } else if item.is_header() {
828 styles.header
829 } else {
830 styles.detail
831 };
832
833 let title_spans = highlight_segments(
834 item.title.as_str(),
835 title_style,
836 styles.search_match,
837 list.highlight_terms(),
838 );
839 primary_spans.extend(title_spans);
840
841 let mut lines = vec![Line::from(primary_spans)];
842
843 if let Some(subtitle) = &item.subtitle {
844 let indent_width = item.indent as usize * 2;
845 let wrapped_width = content_width.saturating_sub(indent_width).max(1);
846 let wrapped_lines = wrap_line_to_width(subtitle.as_str(), wrapped_width);
847
848 for wrapped in wrapped_lines {
849 let mut secondary_spans = Vec::new();
850 if !selection_padding.is_empty() {
851 secondary_spans.push(Span::raw(selection_padding.clone()));
852 }
853 if !indent.is_empty() {
854 secondary_spans.push(Span::raw(indent.clone()));
855 }
856 let subtitle_spans = highlight_segments(
857 wrapped.as_str(),
858 styles.detail,
859 styles.search_match,
860 list.highlight_terms(),
861 );
862 secondary_spans.extend(subtitle_spans);
863 lines.push(Line::from(secondary_spans));
864 }
865 }
866
867 if !list.compact_rows() && item.selection.is_some() {
868 lines.push(Line::default());
869 }
870 lines
871}
872
873fn modal_badge_style(badge: &str, styles: &ModalRenderStyles) -> Style {
874 match badge {
875 "Active" | "Action" => styles.header.add_modifier(Modifier::BOLD),
876 "Read-only" => styles.detail.add_modifier(Modifier::ITALIC),
877 _ => styles.badge,
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use crate::ui::tui::InlineListItem;
885
886 fn line_text(line: &Line<'_>) -> String {
887 line.spans
888 .iter()
889 .map(|span| span.content.clone().into_owned())
890 .collect::<String>()
891 }
892
893 #[test]
894 fn render_markdown_lines_for_modal_wraps_long_questions() {
895 let lines = render_markdown_lines_for_modal(
896 "What user-visible outcome should this change deliver, and what constraints or non-goals must remain unchanged?",
897 40,
898 Style::default(),
899 );
900
901 assert!(lines.len() > 1, "long question should wrap across lines");
902 for line in &lines {
903 let text = line_text(line);
904 assert!(
905 UnicodeWidthStr::width(text.as_str()) <= 40,
906 "line exceeded modal width: {text}"
907 );
908 }
909 }
910
911 #[test]
912 fn render_markdown_lines_for_modal_renders_markdown_headings() {
913 let lines =
914 render_markdown_lines_for_modal("### Goal\n- Reduce prompt size", 80, Style::default());
915
916 let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
917 assert!(rendered.contains("Goal"));
918 assert!(!rendered.contains("### Goal"));
919 assert!(rendered.contains("Reduce prompt size"));
920 }
921
922 #[test]
923 fn config_list_summary_uses_navigation_hint_instead_of_density() {
924 let list = ModalListState::new(
925 vec![InlineListItem {
926 title: "Autonomous mode".to_string(),
927 subtitle: Some("agent.autonomous_mode = on".to_string()),
928 badge: Some("Toggle".to_string()),
929 indent: 0,
930 selection: Some(InlineListSelection::ConfigAction(
931 "agent.autonomous_mode:toggle".to_string(),
932 )),
933 search_value: None,
934 }],
935 None,
936 );
937
938 let styles = ModalRenderStyles {
939 border: Style::default(),
940 highlight: Style::default(),
941 badge: Style::default(),
942 header: Style::default(),
943 selectable: Style::default(),
944 detail: Style::default(),
945 search_match: Style::default(),
946 title: Style::default(),
947 divider: Style::default(),
948 instruction_border: Style::default(),
949 instruction_title: Style::default(),
950 instruction_bullet: Style::default(),
951 instruction_body: Style::default(),
952 hint: Style::default(),
953 };
954
955 let summary = modal_list_summary_line(&list, &styles, None)
956 .expect("expected summary line for config list");
957 let text = line_text(&summary);
958 assert!(text.contains("Navigation:"));
959 assert!(!text.contains("Alt+D"));
960 assert!(!text.contains("Density:"));
961 }
962}