Skip to main content

vtcode_tui/core_tui/session/modal/
render.rs

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