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