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::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/// Render wizard tabs header showing steps with completion status
169#[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/// Render wizard modal body including tabs, question, and list
207#[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    // Layout: [Header (1)] [Search (optional)] [Question (2)] [List] [Notes?] [Instructions?]
241    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 &sections {
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    // Render label
674    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    // Render input field
684    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}