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::{
4    InlineListRenderOptions, InlineListRow, render_inline_list_with_options, row_height,
5    selection_padding, selection_padding_width,
6};
7use crate::ui::tui::session::list_panel::{
8    SharedListPanelSections, SharedListPanelStyles, SharedListWidgetModel, SharedSearchField,
9    render_shared_list_panel, render_shared_search_field,
10};
11use crate::ui::tui::types::{InlineListSelection, SecurePromptConfig};
12use ratatui::{
13    prelude::*,
14    widgets::{Paragraph, Tabs, Wrap},
15};
16use unicode_width::UnicodeWidthStr;
17
18use super::layout::{ModalBodyContext, ModalRenderStyles, ModalSection};
19use super::state::{ModalListState, ModalSearchState, WizardModalState, WizardStepState};
20use crate::ui::tui::session::wrapping;
21use std::mem;
22
23fn modal_text_area_aligned_with_list(area: Rect) -> Rect {
24    let gutter = selection_padding_width().min(area.width as usize) as u16;
25    if gutter == 0 || area.width <= gutter {
26        area
27    } else {
28        Rect {
29            x: area.x.saturating_add(gutter),
30            width: area.width.saturating_sub(gutter),
31            ..area
32        }
33    }
34}
35
36fn markdown_to_plain_lines(text: &str) -> Vec<String> {
37    let mut lines = render_markdown(text)
38        .into_iter()
39        .map(|line| {
40            line.segments
41                .into_iter()
42                .map(|segment| segment.text)
43                .collect::<String>()
44        })
45        .collect::<Vec<_>>();
46
47    while lines.last().is_some_and(|line| line.trim().is_empty()) {
48        lines.pop();
49    }
50
51    if lines.is_empty() {
52        vec![String::new()]
53    } else {
54        lines
55    }
56}
57
58fn wrap_line_to_width(line: &str, width: usize) -> Vec<String> {
59    if width == 0 {
60        return vec![line.to_owned()];
61    }
62
63    if line.is_empty() {
64        return vec![String::new()];
65    }
66
67    let mut rows = Vec::new();
68    let mut current = String::new();
69    let mut current_width = 0usize;
70
71    for ch in line.chars() {
72        let ch_width = unicode_width::UnicodeWidthChar::width(ch)
73            .unwrap_or(0)
74            .max(1);
75        if current_width + ch_width > width && !current.is_empty() {
76            rows.push(mem::take(&mut current));
77            current_width = 0;
78            if ch.is_whitespace() {
79                continue;
80            }
81        }
82
83        current.push(ch);
84        current_width += ch_width;
85    }
86
87    if !current.is_empty() {
88        rows.push(current);
89    }
90
91    if rows.is_empty() {
92        vec![String::new()]
93    } else {
94        rows
95    }
96}
97
98fn render_markdown_lines_for_modal(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
99    let mut lines = Vec::new();
100    for line in markdown_to_plain_lines(text) {
101        let line_ratatui = Line::from(Span::styled(line, style));
102        let wrapped = wrapping::wrap_line_preserving_urls(line_ratatui, width);
103        lines.extend(wrapped);
104    }
105
106    if lines.is_empty() {
107        vec![Line::default()]
108    } else {
109        lines
110    }
111}
112
113#[derive(Clone, Debug)]
114pub struct ModalInlineEditor {
115    item_index: usize,
116    label: String,
117    text: String,
118    placeholder: Option<String>,
119    active: bool,
120}
121
122struct ModalListPanelModel<'a> {
123    list: &'a mut ModalListState,
124    styles: &'a ModalRenderStyles,
125    inline_editor: Option<&'a ModalInlineEditor>,
126}
127
128impl SharedListWidgetModel for ModalListPanelModel<'_> {
129    fn rows(&self, width: u16) -> Vec<(InlineListRow, u16)> {
130        if self.list.visible_indices.is_empty() {
131            return vec![(
132                InlineListRow::single(
133                    Line::from(Span::styled(
134                        ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
135                        self.styles.detail,
136                    )),
137                    self.styles.detail,
138                ),
139                1_u16,
140            )];
141        }
142
143        let selection_gutter = selection_padding_width() as u16;
144        let content_width = width.saturating_sub(selection_gutter) as usize;
145        self.list
146            .visible_indices
147            .iter()
148            .enumerate()
149            .map(|(visible_index, &item_index)| {
150                let lines = modal_list_item_lines(
151                    self.list,
152                    visible_index,
153                    item_index,
154                    self.styles,
155                    content_width,
156                    self.inline_editor,
157                );
158                (
159                    InlineListRow {
160                        lines: lines.clone(),
161                        style: self.styles.selectable,
162                    },
163                    row_height(&lines),
164                )
165            })
166            .collect()
167    }
168
169    fn selected(&self) -> Option<usize> {
170        self.list.list_state.selected()
171    }
172
173    fn set_selected(&mut self, selected: Option<usize>) {
174        self.list.list_state.select(selected);
175    }
176
177    fn set_scroll_offset(&mut self, offset: usize) {
178        *self.list.list_state.offset_mut() = offset;
179    }
180
181    fn set_viewport_rows(&mut self, rows: u16) {
182        self.list.set_viewport_rows(rows);
183        self.list.ensure_visible(rows);
184    }
185}
186
187pub fn render_modal_list(
188    frame: &mut Frame<'_>,
189    area: Rect,
190    list: &mut ModalListState,
191    styles: &ModalRenderStyles,
192    footer_hint: Option<&str>,
193    inline_editor: Option<&ModalInlineEditor>,
194) {
195    if area.width == 0 || area.height == 0 {
196        return;
197    }
198
199    let summary = modal_list_summary_line(list, styles, footer_hint);
200    let mut panel_model = ModalListPanelModel {
201        list,
202        styles,
203        inline_editor,
204    };
205    let sections = SharedListPanelSections {
206        header: Vec::new(),
207        info: summary.into_iter().collect(),
208        search: None,
209    };
210    render_shared_list_panel(
211        frame,
212        area,
213        sections,
214        SharedListPanelStyles {
215            base_style: styles.selectable,
216            selected_style: Some(styles.highlight),
217            text_style: styles.detail,
218        },
219        &mut panel_model,
220    );
221}
222
223/// Render wizard tabs header showing steps with completion status
224#[allow(dead_code)]
225pub fn render_wizard_tabs(
226    frame: &mut Frame<'_>,
227    area: Rect,
228    steps: &[WizardStepState],
229    current_step: usize,
230    styles: &ModalRenderStyles,
231) {
232    if area.height == 0 || area.width == 0 {
233        return;
234    }
235
236    if steps.len() <= 1 {
237        let label = steps
238            .first()
239            .map(|step| {
240                if step.completed {
241                    format!("✔ {}", step.title)
242                } else {
243                    step.title.clone()
244                }
245            })
246            .unwrap_or_default();
247        frame.render_widget(
248            Paragraph::new(Line::from(Span::styled(label, styles.highlight)))
249                .wrap(Wrap { trim: true }),
250            area,
251        );
252        return;
253    }
254
255    let titles: Vec<Line<'static>> = steps
256        .iter()
257        .enumerate()
258        .map(|(i, step)| {
259            let icon = if step.completed { "✔" } else { "☐" };
260            let text = format!("{} {}", icon, step.title);
261            if i == current_step {
262                Line::from(text).style(styles.highlight)
263            } else if step.completed {
264                Line::from(text).style(styles.selectable)
265            } else {
266                Line::from(text).style(styles.detail)
267            }
268        })
269        .collect();
270
271    let tabs = Tabs::new(titles)
272        .select(Some(current_step))
273        .divider(" │ ")
274        .padding("", "")
275        .highlight_style(styles.highlight);
276
277    frame.render_widget(tabs, area);
278}
279
280fn inline_editor_for_step(step: &WizardStepState) -> Option<ModalInlineEditor> {
281    let selected_visible = step.list.list_state.selected()?;
282    let item_index = *step.list.visible_indices.get(selected_visible)?;
283    let item = step.list.items.get(item_index)?;
284
285    match item.selection.as_ref() {
286        Some(InlineListSelection::RequestUserInputAnswer {
287            selected, other, ..
288        }) if selected.is_empty() && other.is_some() => Some(ModalInlineEditor {
289            item_index,
290            label: step
291                .freeform_label
292                .clone()
293                .unwrap_or_else(|| "Custom note".to_string()),
294            text: step.notes.clone(),
295            placeholder: step.freeform_placeholder.clone(),
296            active: step.notes_active,
297        }),
298        _ => None,
299    }
300}
301
302/// Render wizard modal body including tabs, question, and list
303#[allow(dead_code)]
304pub fn render_wizard_modal_body(
305    frame: &mut Frame<'_>,
306    area: Rect,
307    wizard: &mut WizardModalState,
308    styles: &ModalRenderStyles,
309) {
310    if area.width == 0 || area.height == 0 {
311        return;
312    }
313
314    let is_multistep = wizard.mode == crate::ui::tui::types::WizardModalMode::MultiStep;
315    let text_alignment_fn: fn(Rect) -> Rect = if is_multistep {
316        |rect| rect
317    } else {
318        modal_text_area_aligned_with_list
319    };
320    let content_width = text_alignment_fn(area).width.max(1) as usize;
321    let current_step_state = wizard.steps.get(wizard.current_step);
322    let inline_editor = current_step_state.and_then(inline_editor_for_step);
323    let has_notes = current_step_state.is_some_and(|s| s.notes_active || !s.notes.is_empty())
324        && inline_editor.is_none();
325    let instruction_lines = wizard.instruction_lines();
326    let header_lines = if is_multistep {
327        render_markdown_lines_for_modal(
328            wizard.question_header().as_str(),
329            content_width,
330            styles.header,
331        )
332    } else {
333        Vec::new()
334    };
335    let question_lines = wizard
336        .steps
337        .get(wizard.current_step)
338        .map(|step| {
339            render_markdown_lines_for_modal(step.question.as_str(), content_width, styles.header)
340        })
341        .unwrap_or_else(|| vec![Line::default()]);
342
343    let mut info_lines = question_lines;
344    if let Some(step) = wizard.steps.get(wizard.current_step)
345        && has_notes
346    {
347        let label_text = step.freeform_label.as_deref().unwrap_or("›");
348        let mut spans = vec![Span::styled(format!("{} ", label_text), styles.header)];
349
350        if step.notes.is_empty() {
351            if let Some(placeholder) = step.freeform_placeholder.as_ref() {
352                spans.push(Span::styled(placeholder.clone(), styles.detail));
353            }
354        } else {
355            spans.push(Span::styled(step.notes.clone(), styles.selectable));
356        }
357
358        if step.notes_active {
359            spans.push(Span::styled("▌", styles.highlight));
360        }
361        info_lines.push(Line::from(spans));
362    }
363
364    info_lines.extend(
365        instruction_lines
366            .into_iter()
367            .map(|line| Line::from(Span::styled(line, styles.hint))),
368    );
369
370    // Layout: [Header] [Info] [Search?] [Main content list]
371    let mut constraints = Vec::new();
372    if is_multistep {
373        constraints.push(Constraint::Length(
374            header_lines.len().min(u16::MAX as usize) as u16,
375        ));
376    } else {
377        constraints.push(Constraint::Length(1));
378    }
379    constraints.push(Constraint::Length(
380        info_lines.len().max(1).min(u16::MAX as usize) as u16,
381    ));
382    if wizard.search.is_some() {
383        constraints.push(Constraint::Length(1));
384    }
385    constraints.push(Constraint::Min(3));
386
387    let chunks = Layout::vertical(constraints).split(area);
388
389    let mut idx = 0;
390    if is_multistep {
391        let header_area = text_alignment_fn(chunks[idx]);
392        let header = Paragraph::new(header_lines).wrap(Wrap { trim: false });
393        frame.render_widget(header, header_area);
394    } else {
395        let tabs_area = text_alignment_fn(chunks[idx]);
396        render_wizard_tabs(frame, tabs_area, &wizard.steps, wizard.current_step, styles);
397    }
398    idx += 1;
399
400    let info = Paragraph::new(info_lines).wrap(Wrap { trim: false });
401    frame.render_widget(info, text_alignment_fn(chunks[idx]));
402    idx += 1;
403
404    if let Some(search) = wizard.search.as_ref()
405        && idx < chunks.len()
406    {
407        render_modal_search(frame, text_alignment_fn(chunks[idx]), search, styles);
408        idx += 1;
409    }
410
411    if let Some(step) = wizard.steps.get_mut(wizard.current_step)
412        && idx < chunks.len()
413    {
414        render_modal_list(
415            frame,
416            chunks[idx],
417            &mut step.list,
418            styles,
419            None,
420            inline_editor.as_ref(),
421        );
422    }
423}
424
425#[allow(clippy::const_is_empty)]
426fn modal_list_summary_line(
427    list: &ModalListState,
428    styles: &ModalRenderStyles,
429    footer_hint: Option<&str>,
430) -> Option<Line<'static>> {
431    if !list.filter_active() {
432        let message = list.non_filter_summary_text(footer_hint)?;
433        return Some(Line::from(Span::styled(message, styles.hint)));
434    }
435
436    let mut spans = Vec::new();
437    let matches = list.visible_selectable_count();
438    let total = list.total_selectable();
439    if matches == 0 {
440        spans.push(Span::styled(
441            ui::MODAL_LIST_SUMMARY_NO_MATCHES.to_owned(),
442            styles.search_match,
443        ));
444        if !ui::MODAL_LIST_SUMMARY_RESET_HINT.is_empty() {
445            spans.push(Span::styled(
446                format!(
447                    "{}{}",
448                    ui::MODAL_LIST_SUMMARY_SEPARATOR,
449                    ui::MODAL_LIST_SUMMARY_RESET_HINT
450                ),
451                styles.hint,
452            ));
453        }
454    } else {
455        spans.push(Span::styled(
456            format!(
457                "{} {} {} {}",
458                ui::MODAL_LIST_SUMMARY_MATCHES_LABEL,
459                matches,
460                ui::MODAL_LIST_SUMMARY_TOTAL_LABEL,
461                total
462            ),
463            styles.detail,
464        ));
465    }
466
467    if spans.is_empty() {
468        None
469    } else {
470        Some(Line::from(spans))
471    }
472}
473
474pub fn render_modal_body(frame: &mut Frame<'_>, area: Rect, context: ModalBodyContext<'_, '_>) {
475    if area.width == 0 || area.height == 0 {
476        return;
477    }
478
479    let mut sections = Vec::new();
480    let has_instructions = context
481        .instructions
482        .iter()
483        .any(|line| !line.trim().is_empty());
484    if has_instructions {
485        sections.push(ModalSection::Instructions);
486    }
487    if context.secure_prompt.is_some() {
488        sections.push(ModalSection::Prompt);
489    }
490    if context.search.is_some() {
491        sections.push(ModalSection::Search);
492    }
493    if context.list.is_some() {
494        sections.push(ModalSection::List);
495    }
496
497    if sections.is_empty() {
498        return;
499    }
500
501    let mut constraints = Vec::new();
502    for section in &sections {
503        match section {
504            ModalSection::Search => constraints.push(Constraint::Length(1.min(area.height))),
505            ModalSection::Instructions => {
506                let visible_rows = context.instructions.len().clamp(1, 6) as u16;
507                let instruction_title_rows = if ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
508                    0
509                } else {
510                    1
511                };
512                let height = visible_rows.saturating_add(instruction_title_rows);
513                constraints.push(Constraint::Length(height.min(area.height)));
514            }
515            ModalSection::Prompt => constraints.push(Constraint::Length(2.min(area.height))),
516            ModalSection::List => constraints.push(Constraint::Min(1)),
517        }
518    }
519
520    let chunks = Layout::vertical(constraints).split(area);
521    let mut list_state = context.list;
522
523    for (section, chunk) in sections.into_iter().zip(chunks.iter()) {
524        match section {
525            ModalSection::Instructions => {
526                if chunk.height > 0 && has_instructions {
527                    render_modal_instructions(frame, *chunk, context.instructions, context.styles);
528                }
529            }
530            ModalSection::Prompt => {
531                if let Some(config) = context.secure_prompt {
532                    render_secure_prompt(frame, *chunk, config, context.input, context.cursor);
533                }
534            }
535            ModalSection::Search => {
536                if let Some(config) = context.search {
537                    render_modal_search(frame, *chunk, config, context.styles);
538                }
539            }
540            ModalSection::List => {
541                if let Some(list_state) = list_state.as_deref_mut() {
542                    render_modal_list(
543                        frame,
544                        *chunk,
545                        list_state,
546                        context.styles,
547                        context.footer_hint,
548                        None,
549                    );
550                }
551            }
552        }
553    }
554}
555
556fn render_modal_instructions(
557    frame: &mut Frame<'_>,
558    area: Rect,
559    instructions: &[String],
560    styles: &ModalRenderStyles,
561) {
562    fn wrap_instruction_lines(text: &str, width: usize) -> Vec<String> {
563        if width == 0 {
564            return vec![text.to_owned()];
565        }
566
567        let mut lines = Vec::new();
568        let mut current = String::new();
569
570        for word in text.split_whitespace() {
571            let word_width = UnicodeWidthStr::width(word);
572            if current.is_empty() {
573                current.push_str(word);
574                continue;
575            }
576
577            let current_width = UnicodeWidthStr::width(current.as_str());
578            let candidate_width = current_width.saturating_add(1).saturating_add(word_width);
579            if candidate_width > width {
580                lines.push(current);
581                current = word.to_owned();
582            } else {
583                current.push(' ');
584                current.push_str(word);
585            }
586        }
587
588        if !current.is_empty() {
589            lines.push(current);
590        }
591
592        if lines.is_empty() {
593            vec![text.to_owned()]
594        } else {
595            lines
596        }
597    }
598
599    if area.width == 0 || area.height == 0 {
600        return;
601    }
602
603    let mut items: Vec<Vec<Line<'static>>> = Vec::new();
604    let mut first_content_rendered = false;
605    let content_width = area.width.saturating_sub(2) as usize;
606    let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
607    let bullet_indent = " ".repeat(UnicodeWidthStr::width(bullet_prefix.as_str()));
608
609    for line in instructions {
610        let trimmed = line.trim();
611        if trimmed.is_empty() {
612            items.push(vec![Line::default()]);
613            continue;
614        }
615
616        let wrapped = wrap_instruction_lines(trimmed, content_width);
617        if wrapped.is_empty() {
618            items.push(vec![Line::default()]);
619            continue;
620        }
621
622        if !first_content_rendered {
623            let mut lines = Vec::new();
624            for (index, segment) in wrapped.into_iter().enumerate() {
625                let style = if index == 0 {
626                    styles.header
627                } else {
628                    styles.instruction_body
629                };
630                lines.push(Line::from(Span::styled(segment, style)));
631            }
632            items.push(lines);
633            first_content_rendered = true;
634        } else {
635            let mut lines = Vec::new();
636            for (index, segment) in wrapped.into_iter().enumerate() {
637                if index == 0 {
638                    lines.push(Line::from(vec![
639                        Span::styled(bullet_prefix.clone(), styles.instruction_bullet),
640                        Span::styled(segment, styles.instruction_body),
641                    ]));
642                } else {
643                    lines.push(Line::from(vec![
644                        Span::styled(bullet_indent.clone(), styles.instruction_bullet),
645                        Span::styled(segment, styles.instruction_body),
646                    ]));
647                }
648            }
649            items.push(lines);
650        }
651    }
652
653    if items.is_empty() {
654        items.push(vec![Line::default()]);
655    }
656
657    let mut rendered_items = Vec::new();
658    if !ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
659        rendered_items.push((
660            InlineListRow::single(
661                Line::from(Span::styled(
662                    ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
663                    styles.instruction_title,
664                )),
665                styles.instruction_title,
666            ),
667            1_u16,
668        ));
669    }
670
671    rendered_items.extend(items.into_iter().map(|lines| {
672        (
673            InlineListRow {
674                lines: lines.clone(),
675                style: styles.instruction_body,
676            },
677            row_height(&lines),
678        )
679    }));
680
681    let _ = render_inline_list_with_options(
682        frame,
683        area,
684        rendered_items,
685        None,
686        InlineListRenderOptions {
687            base_style: styles.instruction_body,
688            selected_style: None,
689            scroll_padding: ui::INLINE_LIST_SCROLL_PADDING,
690            infinite_scrolling: false,
691        },
692    );
693}
694
695fn render_modal_search(
696    frame: &mut Frame<'_>,
697    area: Rect,
698    search: &ModalSearchState,
699    styles: &ModalRenderStyles,
700) {
701    if area.width == 0 || area.height == 0 {
702        return;
703    }
704
705    let search = SharedSearchField {
706        label: search.label.clone(),
707        placeholder: search.placeholder.clone(),
708        query: search.query.clone(),
709    };
710    render_shared_search_field(
711        frame,
712        area,
713        &search,
714        styles.header,
715        styles.selectable,
716        styles.detail,
717        styles.highlight,
718    );
719}
720
721fn render_secure_prompt(
722    frame: &mut Frame<'_>,
723    area: Rect,
724    config: &SecurePromptConfig,
725    input: &str,
726    _cursor: usize,
727) {
728    if area.width == 0 || area.height == 0 {
729        return;
730    }
731
732    let display_text = if input.is_empty() {
733        config.placeholder.clone().unwrap_or_default()
734    } else if config.mask_input {
735        let grapheme_count = input.chars().count();
736        std::iter::repeat_n('•', grapheme_count).collect()
737    } else {
738        input.to_owned()
739    };
740
741    // Render label
742    let label_paragraph = Paragraph::new(config.label.clone());
743    let label_area = Rect {
744        x: area.x,
745        y: area.y,
746        width: area.width,
747        height: 1.min(area.height),
748    };
749    frame.render_widget(label_paragraph, label_area);
750
751    // Render input field
752    if area.height > 1 {
753        let input_area = Rect {
754            x: area.x,
755            y: area.y + 1,
756            width: area.width,
757            height: (area.height - 1).max(1),
758        };
759
760        let input_paragraph = Paragraph::new(display_text);
761        frame.render_widget(input_paragraph, input_area);
762    }
763}
764
765pub(super) fn highlight_segments(
766    text: &str,
767    normal_style: Style,
768    highlight_style: Style,
769    terms: &[String],
770) -> Vec<Span<'static>> {
771    if text.is_empty() {
772        return vec![Span::styled(String::new(), normal_style)];
773    }
774
775    if terms.is_empty() {
776        return vec![Span::styled(text.to_owned(), normal_style)];
777    }
778
779    let lower = text.to_ascii_lowercase();
780    let mut char_offsets: Vec<usize> = text.char_indices().map(|(offset, _)| offset).collect();
781    char_offsets.push(text.len());
782    let char_count = char_offsets.len().saturating_sub(1);
783    if char_count == 0 {
784        return vec![Span::styled(text.to_owned(), normal_style)];
785    }
786
787    let mut highlight_flags = vec![false; char_count];
788    for term in terms {
789        let needle = term.as_str();
790        if needle.is_empty() {
791            continue;
792        }
793
794        let mut search_start = 0usize;
795        while search_start < lower.len() {
796            let Some(pos) = lower[search_start..].find(needle) else {
797                break;
798            };
799            let byte_start = search_start + pos;
800            let byte_end = byte_start + needle.len();
801            let start_index = char_offsets.partition_point(|offset| *offset < byte_start);
802            let end_index = char_offsets.partition_point(|offset| *offset < byte_end);
803            for flag in highlight_flags
804                .iter_mut()
805                .take(end_index.min(char_count))
806                .skip(start_index)
807            {
808                *flag = true;
809            }
810            search_start = byte_end;
811        }
812    }
813
814    let mut segments = Vec::new();
815    let mut current = String::new();
816    let mut current_highlight = highlight_flags.first().copied().unwrap_or(false);
817    for (idx, ch) in text.chars().enumerate() {
818        let highlight = highlight_flags.get(idx).copied().unwrap_or(false);
819        if idx == 0 {
820            current_highlight = highlight;
821        } else if highlight != current_highlight {
822            let style = if current_highlight {
823                highlight_style
824            } else {
825                normal_style
826            };
827            segments.push(Span::styled(mem::take(&mut current), style));
828            current_highlight = highlight;
829        }
830        current.push(ch);
831    }
832
833    if !current.is_empty() {
834        let style = if current_highlight {
835            highlight_style
836        } else {
837            normal_style
838        };
839        segments.push(Span::styled(current, style));
840    }
841
842    if segments.is_empty() {
843        segments.push(Span::styled(String::new(), normal_style));
844    }
845
846    segments
847}
848
849pub fn modal_list_item_lines(
850    list: &ModalListState,
851    _visible_index: usize,
852    item_index: usize,
853    styles: &ModalRenderStyles,
854    content_width: usize,
855    inline_editor: Option<&ModalInlineEditor>,
856) -> Vec<Line<'static>> {
857    let item = match list.items.get(item_index) {
858        Some(i) => i,
859        None => {
860            tracing::warn!("modal list item index {item_index} out of bounds");
861            return vec![Line::default()];
862        }
863    };
864    if item.is_divider {
865        let divider = if item.title.is_empty() {
866            ui::INLINE_BLOCK_HORIZONTAL.repeat(8)
867        } else {
868            item.title.clone()
869        };
870        return vec![Line::from(Span::styled(divider, styles.divider))];
871    }
872
873    let indent = "  ".repeat(item.indent as usize);
874    let selection_padding = selection_padding();
875
876    let mut primary_spans = Vec::new();
877    if !selection_padding.is_empty() {
878        primary_spans.push(Span::raw(selection_padding.clone()));
879    }
880
881    if !indent.is_empty() {
882        primary_spans.push(Span::raw(indent.clone()));
883    }
884
885    if let Some(badge) = &item.badge {
886        let badge_label = format!("[{}]", badge);
887        primary_spans.push(Span::styled(
888            badge_label,
889            modal_badge_style(badge.as_str(), styles),
890        ));
891        primary_spans.push(Span::raw(" "));
892    }
893
894    let title_style = if item.selection.is_some() {
895        styles.selectable
896    } else if item.is_header() {
897        styles.header
898    } else {
899        styles.detail
900    };
901
902    let title_spans = highlight_segments(
903        item.title.as_str(),
904        title_style,
905        styles.search_match,
906        list.highlight_terms(),
907    );
908    primary_spans.extend(title_spans);
909
910    let mut lines = vec![Line::from(primary_spans)];
911
912    if let Some(subtitle) = &item.subtitle {
913        let indent_width = item.indent as usize * 2;
914        let wrapped_width = content_width.saturating_sub(indent_width).max(1);
915        let wrapped_lines = wrap_line_to_width(subtitle.as_str(), wrapped_width);
916
917        for wrapped in wrapped_lines {
918            let mut secondary_spans = Vec::new();
919            if !selection_padding.is_empty() {
920                secondary_spans.push(Span::raw(selection_padding.clone()));
921            }
922            if !indent.is_empty() {
923                secondary_spans.push(Span::raw(indent.clone()));
924            }
925            let subtitle_spans = highlight_segments(
926                wrapped.as_str(),
927                styles.detail,
928                styles.search_match,
929                list.highlight_terms(),
930            );
931            secondary_spans.extend(subtitle_spans);
932            lines.push(Line::from(secondary_spans));
933        }
934    }
935
936    if let Some(editor) = inline_editor
937        && editor.item_index == item_index
938    {
939        let mut editor_spans = Vec::new();
940        if !selection_padding.is_empty() {
941            editor_spans.push(Span::raw(selection_padding.clone()));
942        }
943        if !indent.is_empty() {
944            editor_spans.push(Span::raw(indent.clone()));
945        }
946
947        editor_spans.push(Span::styled(format!("{} ", editor.label), styles.header));
948        if editor.text.is_empty() {
949            if let Some(placeholder) = editor.placeholder.as_ref() {
950                editor_spans.push(Span::styled(placeholder.clone(), styles.detail));
951            }
952        } else {
953            editor_spans.push(Span::styled(editor.text.clone(), styles.selectable));
954        }
955
956        if editor.active {
957            editor_spans.push(Span::styled("▌", styles.highlight));
958        }
959
960        lines.push(Line::from(editor_spans));
961    }
962
963    if !list.compact_rows() && item.selection.is_some() {
964        lines.push(Line::default());
965    }
966    lines
967}
968
969fn modal_badge_style(badge: &str, styles: &ModalRenderStyles) -> Style {
970    match badge {
971        "Active" | "Action" => styles.header.add_modifier(Modifier::BOLD),
972        "Read-only" => styles.detail.add_modifier(Modifier::ITALIC),
973        _ => styles.badge,
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use crate::ui::tui::InlineListItem;
981    use ratatui::{Terminal, backend::TestBackend};
982
983    fn line_text(line: &Line<'_>) -> String {
984        line.spans
985            .iter()
986            .map(|span| span.content.clone().into_owned())
987            .collect::<String>()
988    }
989
990    fn modal_render_styles() -> ModalRenderStyles {
991        ModalRenderStyles {
992            border: Style::default(),
993            highlight: Style::default(),
994            badge: Style::default(),
995            header: Style::default(),
996            selectable: Style::default(),
997            detail: Style::default(),
998            search_match: Style::default(),
999            title: Style::default(),
1000            divider: Style::default(),
1001            instruction_border: Style::default(),
1002            instruction_title: Style::default(),
1003            instruction_bullet: Style::default(),
1004            instruction_body: Style::default(),
1005            hint: Style::default(),
1006        }
1007    }
1008
1009    fn render_modal_lines(search: ModalSearchState) -> Vec<String> {
1010        let styles = modal_render_styles();
1011        let mut list = ModalListState::new(
1012            vec![InlineListItem {
1013                title: "Alpha".to_string(),
1014                subtitle: Some("First item".to_string()),
1015                badge: Some("OpenAI".to_string()),
1016                indent: 0,
1017                selection: Some(InlineListSelection::Model(0)),
1018                search_value: Some("alpha".to_string()),
1019            }],
1020            None,
1021        );
1022        let instructions = vec!["Choose a model".to_string()];
1023        let backend = TestBackend::new(80, 8);
1024        let mut terminal = Terminal::new(backend).expect("test terminal");
1025
1026        terminal
1027            .draw(|frame| {
1028                render_modal_body(
1029                    frame,
1030                    Rect::new(0, 0, 80, 8),
1031                    ModalBodyContext {
1032                        instructions: &instructions,
1033                        footer_hint: None,
1034                        list: Some(&mut list),
1035                        styles: &styles,
1036                        secure_prompt: None,
1037                        search: Some(&search),
1038                        input: "",
1039                        cursor: 0,
1040                    },
1041                );
1042            })
1043            .expect("modal render should succeed");
1044
1045        let buffer = terminal.backend().buffer();
1046        (0..buffer.area.height)
1047            .map(|y| {
1048                (0..buffer.area.width)
1049                    .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string()))
1050                    .collect::<String>()
1051                    .trim_end()
1052                    .to_string()
1053            })
1054            .collect()
1055    }
1056
1057    #[test]
1058    fn render_markdown_lines_for_modal_wraps_long_questions() {
1059        let lines = render_markdown_lines_for_modal(
1060            "What user-visible outcome should this change deliver, and what constraints or non-goals must remain unchanged?",
1061            40,
1062            Style::default(),
1063        );
1064
1065        assert!(lines.len() > 1, "long question should wrap across lines");
1066        for line in &lines {
1067            let text = line_text(line);
1068            assert!(
1069                UnicodeWidthStr::width(text.as_str()) <= 40,
1070                "line exceeded modal width: {text}"
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn render_markdown_lines_for_modal_renders_markdown_headings() {
1077        let lines =
1078            render_markdown_lines_for_modal("### Goal\n- Reduce prompt size", 80, Style::default());
1079
1080        let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
1081        assert!(rendered.contains("Goal"));
1082        assert!(!rendered.contains("### Goal"));
1083        assert!(rendered.contains("Reduce prompt size"));
1084    }
1085
1086    #[test]
1087    fn config_list_summary_uses_navigation_hint_instead_of_density() {
1088        let list = ModalListState::new(
1089            vec![InlineListItem {
1090                title: "Autonomous mode".to_string(),
1091                subtitle: Some("agent.autonomous_mode = on".to_string()),
1092                badge: Some("Toggle".to_string()),
1093                indent: 0,
1094                selection: Some(InlineListSelection::ConfigAction(
1095                    "agent.autonomous_mode:toggle".to_string(),
1096                )),
1097                search_value: None,
1098            }],
1099            None,
1100        );
1101
1102        let styles = ModalRenderStyles {
1103            border: Style::default(),
1104            highlight: Style::default(),
1105            badge: Style::default(),
1106            header: Style::default(),
1107            selectable: Style::default(),
1108            detail: Style::default(),
1109            search_match: Style::default(),
1110            title: Style::default(),
1111            divider: Style::default(),
1112            instruction_border: Style::default(),
1113            instruction_title: Style::default(),
1114            instruction_bullet: Style::default(),
1115            instruction_body: Style::default(),
1116            hint: Style::default(),
1117        };
1118
1119        let summary = modal_list_summary_line(&list, &styles, None)
1120            .expect("expected summary line for config list");
1121        let text = line_text(&summary);
1122        assert!(text.contains("Navigation:"));
1123        assert!(!text.contains("Alt+D"));
1124        assert!(!text.contains("Density:"));
1125    }
1126
1127    #[test]
1128    fn non_config_list_summary_omits_density_hint() {
1129        let list = ModalListState::new(
1130            vec![InlineListItem {
1131                title: "gpt-5".to_string(),
1132                subtitle: Some("General reasoning".to_string()),
1133                badge: None,
1134                indent: 0,
1135                selection: Some(InlineListSelection::Model(0)),
1136                search_value: Some("gpt-5".to_string()),
1137            }],
1138            None,
1139        );
1140
1141        let styles = ModalRenderStyles {
1142            border: Style::default(),
1143            highlight: Style::default(),
1144            badge: Style::default(),
1145            header: Style::default(),
1146            selectable: Style::default(),
1147            detail: Style::default(),
1148            search_match: Style::default(),
1149            title: Style::default(),
1150            divider: Style::default(),
1151            instruction_border: Style::default(),
1152            instruction_title: Style::default(),
1153            instruction_bullet: Style::default(),
1154            instruction_body: Style::default(),
1155            hint: Style::default(),
1156        };
1157
1158        let summary = modal_list_summary_line(&list, &styles, None);
1159        assert!(summary.is_none(), "density summary should be hidden");
1160    }
1161
1162    #[test]
1163    fn modal_text_area_alignment_reserves_selection_gutter() {
1164        let area = Rect::new(10, 3, 20, 4);
1165        let aligned = modal_text_area_aligned_with_list(area);
1166        let gutter = selection_padding_width() as u16;
1167
1168        assert_eq!(aligned.x, area.x + gutter);
1169        assert_eq!(aligned.width, area.width - gutter);
1170        assert_eq!(aligned.y, area.y);
1171        assert_eq!(aligned.height, area.height);
1172    }
1173
1174    #[test]
1175    fn modal_text_area_alignment_keeps_narrow_areas_unchanged() {
1176        let gutter = selection_padding_width() as u16;
1177        let area = Rect::new(2, 1, gutter, 2);
1178        let aligned = modal_text_area_aligned_with_list(area);
1179        assert_eq!(aligned, area);
1180    }
1181
1182    #[test]
1183    fn modal_search_field_renders_placeholder_inside_brackets() {
1184        let lines = render_modal_lines(ModalSearchState {
1185            label: "Search models".to_string(),
1186            placeholder: Some("provider, name, id".to_string()),
1187            query: String::new(),
1188        });
1189
1190        let search_line = lines
1191            .iter()
1192            .find(|line| line.contains("Search models:"))
1193            .expect("search line should render");
1194        assert!(search_line.contains("[provider, name, id"));
1195        assert!(search_line.contains("]"));
1196    }
1197
1198    #[test]
1199    fn modal_search_field_renders_query_above_list() {
1200        let lines = render_modal_lines(ModalSearchState {
1201            label: "Search models".to_string(),
1202            placeholder: Some("provider, name, id".to_string()),
1203            query: "openrouter".to_string(),
1204        });
1205
1206        let search_index = lines
1207            .iter()
1208            .position(|line| line.contains("Search models: [openrouter"))
1209            .expect("search query should render");
1210        let item_index = lines
1211            .iter()
1212            .position(|line| line.contains("Alpha"))
1213            .expect("list item should render");
1214
1215        assert!(lines[search_index].contains("Esc clears"));
1216        assert!(search_index < item_index);
1217    }
1218
1219    #[test]
1220    fn filtered_modal_summary_shows_matches_without_repeating_query() {
1221        let list = ModalListState::new(
1222            vec![InlineListItem {
1223                title: "gpt-5".to_string(),
1224                subtitle: Some("General reasoning".to_string()),
1225                badge: None,
1226                indent: 0,
1227                selection: Some(InlineListSelection::Model(0)),
1228                search_value: Some("gpt-5".to_string()),
1229            }],
1230            None,
1231        );
1232        let styles = modal_render_styles();
1233        let mut list = list;
1234        list.apply_search("gpt");
1235
1236        let summary = modal_list_summary_line(&list, &styles, None).expect("summary should exist");
1237        let text = line_text(&summary);
1238
1239        assert!(text.contains("Matches 1 of 1"));
1240        assert!(!text.contains("gpt"));
1241        assert!(!text.contains("Filter:"));
1242    }
1243}