Skip to main content

vtcode_tui/core_tui/session/modal/
state.rs

1use crate::config::constants::ui;
2use crate::ui::search::{exact_terms_match, normalize_query};
3use crate::ui::tui::types::{
4    InlineEvent, InlineListItem, InlineListSearchConfig, InlineListSelection, OverlayEvent,
5    OverlayHotkey, OverlayHotkeyAction, OverlayHotkeyKey, OverlaySelectionChange,
6    OverlaySubmission, SecurePromptConfig, WizardModalMode, WizardStep,
7};
8use ratatui::crossterm::event::{KeyCode, KeyEvent};
9use ratatui::widgets::ListState;
10
11#[derive(Clone)]
12pub struct ModalState {
13    pub title: String,
14    pub lines: Vec<String>,
15    pub footer_hint: Option<String>,
16    pub hotkeys: Vec<OverlayHotkey>,
17    pub list: Option<ModalListState>,
18    pub secure_prompt: Option<SecurePromptConfig>,
19    #[allow(dead_code)]
20    pub restore_input: bool,
21    #[allow(dead_code)]
22    pub restore_cursor: bool,
23    pub search: Option<ModalSearchState>,
24}
25
26/// State for a multi-step wizard modal with tabs for navigation
27#[allow(dead_code)]
28#[derive(Clone)]
29pub struct WizardModalState {
30    pub title: String,
31    pub steps: Vec<WizardStepState>,
32    pub current_step: usize,
33    pub search: Option<ModalSearchState>,
34    pub mode: WizardModalMode,
35}
36
37/// State for a single wizard step
38#[allow(dead_code)]
39#[derive(Clone)]
40pub struct WizardStepState {
41    /// Title displayed in the tab header
42    pub title: String,
43    /// Question or instruction shown above the list
44    pub question: String,
45    /// List state for selectable items
46    pub list: ModalListState,
47    /// Whether this step has been completed
48    pub completed: bool,
49    /// The selected answer for this step
50    pub answer: Option<InlineListSelection>,
51    /// Optional notes for the current step (free text)
52    pub notes: String,
53    /// Whether notes input is active for the current step
54    pub notes_active: bool,
55
56    pub allow_freeform: bool,
57    pub freeform_label: Option<String>,
58    pub freeform_placeholder: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, Default)]
62pub struct ModalKeyModifiers {
63    pub control: bool,
64    pub alt: bool,
65    pub command: bool,
66}
67
68#[derive(Debug, Clone)]
69pub enum ModalListKeyResult {
70    NotHandled,
71    HandledNoRedraw,
72    Redraw,
73    Emit(InlineEvent),
74    Submit(InlineEvent),
75    Cancel(InlineEvent),
76}
77
78#[derive(Clone)]
79pub struct ModalListState {
80    pub items: Vec<ModalListItem>,
81    pub visible_indices: Vec<usize>,
82    pub list_state: ListState,
83    pub total_selectable: usize,
84    pub filter_terms: Vec<String>,
85    pub filter_query: Option<String>,
86    pub viewport_rows: Option<u16>,
87    pub compact_rows: bool,
88    density_behavior: ModalListDensityBehavior,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92enum ModalListDensityBehavior {
93    Adjustable,
94    FixedComfortable,
95}
96
97const CONFIG_LIST_NAVIGATION_HINT: &str =
98    "Navigation: ↑/↓ select • Space/Enter apply • ←/→ change value • Esc close";
99
100#[derive(Clone)]
101pub struct ModalListItem {
102    pub title: String,
103    pub subtitle: Option<String>,
104    pub badge: Option<String>,
105    pub indent: u8,
106    pub selection: Option<InlineListSelection>,
107    pub search_value: Option<String>,
108    pub is_divider: bool,
109}
110
111#[derive(Clone)]
112pub struct ModalSearchState {
113    pub label: String,
114    pub placeholder: Option<String>,
115    pub query: String,
116}
117
118impl From<InlineListSearchConfig> for ModalSearchState {
119    fn from(config: InlineListSearchConfig) -> Self {
120        Self {
121            label: config.label,
122            placeholder: config.placeholder,
123            query: String::new(),
124        }
125    }
126}
127
128impl ModalSearchState {
129    pub fn insert(&mut self, value: &str) {
130        for ch in value.chars() {
131            if matches!(ch, '\n' | '\r') {
132                continue;
133            }
134            self.query.push(ch);
135        }
136    }
137
138    pub fn push_char(&mut self, ch: char) {
139        self.query.push(ch);
140    }
141
142    pub fn backspace(&mut self) -> bool {
143        if self.query.pop().is_some() {
144            return true;
145        }
146        false
147    }
148
149    pub fn clear(&mut self) -> bool {
150        if self.query.is_empty() {
151            return false;
152        }
153        self.query.clear();
154        true
155    }
156}
157
158impl ModalState {
159    pub fn hotkey_action(
160        &self,
161        key: &KeyEvent,
162        modifiers: ModalKeyModifiers,
163    ) -> Option<OverlayHotkeyAction> {
164        self.hotkeys.iter().find_map(|hotkey| match hotkey.key {
165            OverlayHotkeyKey::CtrlChar(ch)
166                if modifiers.control
167                    && !modifiers.alt
168                    && !modifiers.command
169                    && matches!(key.code, KeyCode::Char(key_ch) if key_ch.eq_ignore_ascii_case(&ch))
170            =>
171            {
172                Some(hotkey.action.clone())
173            }
174            _ => None,
175        })
176    }
177
178    pub fn handle_list_key_event(
179        &mut self,
180        key: &KeyEvent,
181        modifiers: ModalKeyModifiers,
182    ) -> ModalListKeyResult {
183        let Some(list) = self.list.as_mut() else {
184            return ModalListKeyResult::NotHandled;
185        };
186
187        if let Some(search) = self.search.as_mut() {
188            match key.code {
189                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
190                    let previous = list.current_selection();
191                    search.push_char(ch);
192                    list.apply_search(&search.query);
193                    if let Some(event) = selection_change_event(list, previous) {
194                        return ModalListKeyResult::Emit(event);
195                    }
196                    return ModalListKeyResult::Redraw;
197                }
198                KeyCode::Backspace => {
199                    if search.backspace() {
200                        let previous = list.current_selection();
201                        list.apply_search(&search.query);
202                        if let Some(event) = selection_change_event(list, previous) {
203                            return ModalListKeyResult::Emit(event);
204                        }
205                        return ModalListKeyResult::Redraw;
206                    }
207                    return ModalListKeyResult::HandledNoRedraw;
208                }
209                KeyCode::Delete => {
210                    if search.clear() {
211                        let previous = list.current_selection();
212                        list.apply_search(&search.query);
213                        if let Some(event) = selection_change_event(list, previous) {
214                            return ModalListKeyResult::Emit(event);
215                        }
216                        return ModalListKeyResult::Redraw;
217                    }
218                    return ModalListKeyResult::HandledNoRedraw;
219                }
220                KeyCode::Esc => {
221                    if search.clear() {
222                        let previous = list.current_selection();
223                        list.apply_search(&search.query);
224                        if let Some(event) = selection_change_event(list, previous) {
225                            return ModalListKeyResult::Emit(event);
226                        }
227                        return ModalListKeyResult::Redraw;
228                    }
229                }
230                _ => {}
231            }
232        }
233
234        let previous_selection = list.current_selection();
235        match key.code {
236            KeyCode::Char('d') | KeyCode::Char('D') if modifiers.alt => {
237                if !list.supports_density_toggle() {
238                    return ModalListKeyResult::HandledNoRedraw;
239                }
240                list.toggle_row_density();
241                ModalListKeyResult::Redraw
242            }
243            KeyCode::Up => {
244                if modifiers.command {
245                    list.select_first();
246                } else {
247                    list.select_previous();
248                }
249                if let Some(event) = selection_change_event(list, previous_selection) {
250                    ModalListKeyResult::Emit(event)
251                } else {
252                    ModalListKeyResult::Redraw
253                }
254            }
255            KeyCode::Down => {
256                if modifiers.command {
257                    list.select_last();
258                } else {
259                    list.select_next();
260                }
261                if let Some(event) = selection_change_event(list, previous_selection) {
262                    ModalListKeyResult::Emit(event)
263                } else {
264                    ModalListKeyResult::Redraw
265                }
266            }
267            KeyCode::PageUp => {
268                list.page_up();
269                if let Some(event) = selection_change_event(list, previous_selection) {
270                    ModalListKeyResult::Emit(event)
271                } else {
272                    ModalListKeyResult::Redraw
273                }
274            }
275            KeyCode::PageDown => {
276                list.page_down();
277                if let Some(event) = selection_change_event(list, previous_selection) {
278                    ModalListKeyResult::Emit(event)
279                } else {
280                    ModalListKeyResult::Redraw
281                }
282            }
283            KeyCode::Home => {
284                list.select_first();
285                if let Some(event) = selection_change_event(list, previous_selection) {
286                    ModalListKeyResult::Emit(event)
287                } else {
288                    ModalListKeyResult::Redraw
289                }
290            }
291            KeyCode::End => {
292                list.select_last();
293                if let Some(event) = selection_change_event(list, previous_selection) {
294                    ModalListKeyResult::Emit(event)
295                } else {
296                    ModalListKeyResult::Redraw
297                }
298            }
299            KeyCode::Tab => {
300                // With no search active, Tab moves to first item for autocomplete behavior
301                // If search is active, we already handled it above
302                if self.search.is_none() && !list.visible_indices.is_empty() {
303                    list.select_first();
304                } else {
305                    list.select_next();
306                }
307                if let Some(event) = selection_change_event(list, previous_selection) {
308                    ModalListKeyResult::Emit(event)
309                } else {
310                    ModalListKeyResult::Redraw
311                }
312            }
313            KeyCode::BackTab => {
314                list.select_previous();
315                if let Some(event) = selection_change_event(list, previous_selection) {
316                    ModalListKeyResult::Emit(event)
317                } else {
318                    ModalListKeyResult::Redraw
319                }
320            }
321            KeyCode::Left => {
322                if let Some(selection) = list.current_selection()
323                    && let Some(adjusted) = map_config_selection_for_arrow(&selection, true)
324                {
325                    return ModalListKeyResult::Submit(InlineEvent::Overlay(
326                        OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
327                    ));
328                }
329                list.select_previous();
330                if let Some(event) = selection_change_event(list, previous_selection) {
331                    ModalListKeyResult::Emit(event)
332                } else {
333                    ModalListKeyResult::Redraw
334                }
335            }
336            KeyCode::Right => {
337                if let Some(selection) = list.current_selection()
338                    && let Some(adjusted) = map_config_selection_for_arrow(&selection, false)
339                {
340                    return ModalListKeyResult::Submit(InlineEvent::Overlay(
341                        OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
342                    ));
343                }
344                list.select_next();
345                if let Some(event) = selection_change_event(list, previous_selection) {
346                    ModalListKeyResult::Emit(event)
347                } else {
348                    ModalListKeyResult::Redraw
349                }
350            }
351            KeyCode::Enter => {
352                if let Some(selection) = list.current_selection() {
353                    ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
354                        OverlaySubmission::Selection(selection),
355                    )))
356                } else {
357                    ModalListKeyResult::HandledNoRedraw
358                }
359            }
360            KeyCode::Esc => {
361                ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
362            }
363            KeyCode::Char(ch) if modifiers.control || modifiers.alt => match ch {
364                'n' | 'N' | 'j' | 'J' => {
365                    list.select_next();
366                    if let Some(event) = selection_change_event(list, previous_selection) {
367                        ModalListKeyResult::Emit(event)
368                    } else {
369                        ModalListKeyResult::Redraw
370                    }
371                }
372                'p' | 'P' | 'k' | 'K' => {
373                    list.select_previous();
374                    if let Some(event) = selection_change_event(list, previous_selection) {
375                        ModalListKeyResult::Emit(event)
376                    } else {
377                        ModalListKeyResult::Redraw
378                    }
379                }
380                _ => ModalListKeyResult::NotHandled,
381            },
382            _ => ModalListKeyResult::NotHandled,
383        }
384    }
385
386    pub fn handle_list_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
387        let Some(list) = self.list.as_mut() else {
388            return ModalListKeyResult::NotHandled;
389        };
390        let Some(&item_index) = list.visible_indices.get(visible_index) else {
391            return ModalListKeyResult::HandledNoRedraw;
392        };
393        if list
394            .items
395            .get(item_index)
396            .and_then(|item| item.selection.as_ref())
397            .is_none()
398        {
399            return ModalListKeyResult::HandledNoRedraw;
400        }
401
402        let previous_selection = list.current_selection();
403        if list.list_state.selected() == Some(visible_index) {
404            if let Some(selection) = list.current_selection() {
405                return ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
406                    OverlaySubmission::Selection(selection),
407                )));
408            }
409            return ModalListKeyResult::HandledNoRedraw;
410        }
411
412        list.list_state.select(Some(visible_index));
413        if let Some(rows) = list.viewport_rows {
414            list.ensure_visible(rows);
415        }
416        if let Some(event) = selection_change_event(list, previous_selection) {
417            ModalListKeyResult::Emit(event)
418        } else {
419            ModalListKeyResult::Redraw
420        }
421    }
422
423    pub fn handle_list_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
424        let Some(list) = self.list.as_mut() else {
425            return ModalListKeyResult::NotHandled;
426        };
427
428        let previous_selection = list.current_selection();
429        if down {
430            list.select_next();
431        } else {
432            list.select_previous();
433        }
434
435        if let Some(event) = selection_change_event(list, previous_selection) {
436            ModalListKeyResult::Emit(event)
437        } else {
438            ModalListKeyResult::Redraw
439        }
440    }
441}
442
443fn selection_change_event(
444    list: &ModalListState,
445    previous: Option<InlineListSelection>,
446) -> Option<InlineEvent> {
447    let current = list.current_selection();
448    if current == previous {
449        return None;
450    }
451    current.map(|selection| {
452        InlineEvent::Overlay(OverlayEvent::SelectionChanged(
453            OverlaySelectionChange::List(selection),
454        ))
455    })
456}
457
458fn map_config_selection_for_arrow(
459    selection: &InlineListSelection,
460    is_left: bool,
461) -> Option<InlineListSelection> {
462    let InlineListSelection::ConfigAction(action) = selection else {
463        return None;
464    };
465
466    if action.ends_with(":cycle") {
467        if is_left {
468            let key = action.trim_end_matches(":cycle");
469            return Some(InlineListSelection::ConfigAction(format!(
470                "{}:cycle_prev",
471                key
472            )));
473        }
474        return Some(selection.clone());
475    }
476
477    if action.ends_with(":inc") {
478        if is_left {
479            let key = action.trim_end_matches(":inc");
480            return Some(InlineListSelection::ConfigAction(format!("{}:dec", key)));
481        }
482        return Some(selection.clone());
483    }
484
485    if action.ends_with(":dec") {
486        if is_left {
487            return Some(selection.clone());
488        }
489        let key = action.trim_end_matches(":dec");
490        return Some(InlineListSelection::ConfigAction(format!("{}:inc", key)));
491    }
492
493    if action.ends_with(":toggle") {
494        let _ = is_left;
495        return Some(selection.clone());
496    }
497
498    None
499}
500
501impl ModalListItem {
502    pub fn is_header(&self) -> bool {
503        self.selection.is_none() && !self.is_divider
504    }
505
506    fn matches(&self, query: &str) -> bool {
507        if query.is_empty() {
508            return true;
509        }
510        let Some(value) = self.search_value.as_ref() else {
511            return false;
512        };
513        exact_terms_match(query, value)
514    }
515}
516
517#[allow(clippy::const_is_empty)]
518pub fn is_divider_title(item: &InlineListItem) -> bool {
519    if item.selection.is_some() {
520        return false;
521    }
522    if item.indent != 0 {
523        return false;
524    }
525    if item.subtitle.is_some() || item.badge.is_some() {
526        return false;
527    }
528    let symbol = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL;
529    if symbol.is_empty() {
530        return false;
531    }
532    item.title
533        .chars()
534        .all(|ch| symbol.chars().any(|needle| needle == ch))
535}
536
537impl ModalListState {
538    pub fn new(items: Vec<InlineListItem>, selected: Option<InlineListSelection>) -> Self {
539        let converted: Vec<ModalListItem> = items
540            .into_iter()
541            .map(|item| {
542                let is_divider = is_divider_title(&item);
543                let search_value = item
544                    .search_value
545                    .as_ref()
546                    .map(|value| value.to_ascii_lowercase());
547                ModalListItem {
548                    title: item.title,
549                    subtitle: item.subtitle,
550                    badge: item.badge,
551                    indent: item.indent,
552                    selection: item.selection,
553                    search_value,
554                    is_divider,
555                }
556            })
557            .collect();
558        let total_selectable = converted
559            .iter()
560            .filter(|item| item.selection.is_some())
561            .count();
562        let has_two_line_items = converted.iter().any(|item| {
563            item.subtitle
564                .as_ref()
565                .is_some_and(|subtitle| !subtitle.trim().is_empty())
566        });
567        let density_behavior = Self::density_behavior_for_items(&converted);
568        let is_model_picker_list = Self::is_model_picker_list(&converted);
569        let compact_rows =
570            Self::initial_compact_rows(density_behavior, has_two_line_items, is_model_picker_list);
571        let mut modal_state = Self {
572            visible_indices: (0..converted.len()).collect(),
573            items: converted,
574            list_state: ListState::default(),
575            total_selectable,
576            filter_terms: Vec::new(),
577            filter_query: None,
578            viewport_rows: None,
579            compact_rows,
580            density_behavior,
581        };
582        modal_state.select_initial(selected);
583        modal_state
584    }
585
586    fn density_behavior_for_items(items: &[ModalListItem]) -> ModalListDensityBehavior {
587        if items
588            .iter()
589            .any(|item| matches!(item.selection, Some(InlineListSelection::ConfigAction(_))))
590        {
591            ModalListDensityBehavior::FixedComfortable
592        } else {
593            ModalListDensityBehavior::Adjustable
594        }
595    }
596
597    fn initial_compact_rows(
598        density_behavior: ModalListDensityBehavior,
599        has_two_line_items: bool,
600        is_model_picker_list: bool,
601    ) -> bool {
602        if is_model_picker_list {
603            return false;
604        }
605        match density_behavior {
606            ModalListDensityBehavior::FixedComfortable => false,
607            ModalListDensityBehavior::Adjustable => has_two_line_items,
608        }
609    }
610
611    fn is_model_picker_list(items: &[ModalListItem]) -> bool {
612        let mut has_model_selection = false;
613        for item in items {
614            let Some(selection) = item.selection.as_ref() else {
615                continue;
616            };
617            match selection {
618                InlineListSelection::Model(_)
619                | InlineListSelection::DynamicModel(_)
620                | InlineListSelection::RefreshDynamicModels
621                | InlineListSelection::Reasoning(_)
622                | InlineListSelection::DisableReasoning
623                | InlineListSelection::CustomModel => {
624                    has_model_selection = true;
625                }
626                _ => return false,
627            }
628        }
629        has_model_selection
630    }
631
632    pub fn current_selection(&self) -> Option<InlineListSelection> {
633        self.list_state
634            .selected()
635            .and_then(|index| self.visible_indices.get(index))
636            .and_then(|&item_index| self.items.get(item_index))
637            .and_then(|item| item.selection.clone())
638    }
639
640    pub fn get_best_matching_item(&self, query: &str) -> Option<String> {
641        if query.is_empty() {
642            return None;
643        }
644
645        let normalized_query = normalize_query(query);
646        self.visible_indices
647            .iter()
648            .filter_map(|&idx| self.items.get(idx))
649            .filter(|item| item.selection.is_some())
650            .filter_map(|item| item.search_value.as_ref())
651            .find(|search_value| exact_terms_match(&normalized_query, search_value))
652            .cloned()
653    }
654
655    pub fn select_previous(&mut self) {
656        if self.visible_indices.is_empty() {
657            return;
658        }
659        let Some(mut index) = self.list_state.selected() else {
660            if let Some(last) = self.last_selectable_index() {
661                self.list_state.select(Some(last));
662            }
663            return;
664        };
665
666        while index > 0 {
667            index -= 1;
668            let item_index = match self.visible_indices.get(index) {
669                Some(idx) => *idx,
670                None => {
671                    tracing::warn!("visible_indices index {index} out of bounds");
672                    continue;
673                }
674            };
675            if let Some(item) = self.items.get(item_index)
676                && item.selection.is_some()
677            {
678                self.list_state.select(Some(index));
679                return;
680            }
681        }
682
683        if let Some(first) = self.first_selectable_index() {
684            self.list_state.select(Some(first));
685        } else {
686            self.list_state.select(None);
687        }
688    }
689
690    pub fn select_next(&mut self) {
691        if self.visible_indices.is_empty() {
692            return;
693        }
694        let mut index = self.list_state.selected().unwrap_or(usize::MAX);
695        if index == usize::MAX {
696            if let Some(first) = self.first_selectable_index() {
697                self.list_state.select(Some(first));
698            }
699            return;
700        }
701        while index + 1 < self.visible_indices.len() {
702            index += 1;
703            let item_index = self.visible_indices[index];
704            if self.items[item_index].selection.is_some() {
705                self.list_state.select(Some(index));
706                break;
707            }
708        }
709    }
710
711    pub fn select_first(&mut self) {
712        if let Some(first) = self.first_selectable_index() {
713            self.list_state.select(Some(first));
714        } else {
715            self.list_state.select(None);
716        }
717        if let Some(rows) = self.viewport_rows {
718            self.ensure_visible(rows);
719        }
720    }
721
722    pub fn select_last(&mut self) {
723        if let Some(last) = self.last_selectable_index() {
724            self.list_state.select(Some(last));
725        } else {
726            self.list_state.select(None);
727        }
728        if let Some(rows) = self.viewport_rows {
729            self.ensure_visible(rows);
730        }
731    }
732
733    pub(crate) fn selected_is_last(&self) -> bool {
734        let Some(selected) = self.list_state.selected() else {
735            return false;
736        };
737        self.last_selectable_index()
738            .is_some_and(|last| selected == last)
739    }
740
741    pub fn select_nth_selectable(&mut self, target_index: usize) -> bool {
742        let mut count = 0usize;
743        for (visible_pos, &item_index) in self.visible_indices.iter().enumerate() {
744            if self.items[item_index].selection.is_some() {
745                if count == target_index {
746                    self.list_state.select(Some(visible_pos));
747                    if let Some(rows) = self.viewport_rows {
748                        self.ensure_visible(rows);
749                    }
750                    return true;
751                }
752                count += 1;
753            }
754        }
755        false
756    }
757
758    pub fn page_up(&mut self) {
759        let step = self.page_step();
760        if step == 0 {
761            self.select_previous();
762            return;
763        }
764        for _ in 0..step {
765            let before = self.list_state.selected();
766            self.select_previous();
767            if self.list_state.selected() == before {
768                break;
769            }
770        }
771    }
772
773    pub fn page_down(&mut self) {
774        let step = self.page_step();
775        if step == 0 {
776            self.select_next();
777            return;
778        }
779        for _ in 0..step {
780            let before = self.list_state.selected();
781            self.select_next();
782            if self.list_state.selected() == before {
783                break;
784            }
785        }
786    }
787
788    pub fn set_viewport_rows(&mut self, rows: u16) {
789        self.viewport_rows = Some(rows);
790    }
791
792    pub(super) fn ensure_visible(&mut self, viewport: u16) {
793        let Some(selected) = self.list_state.selected() else {
794            return;
795        };
796        if viewport == 0 {
797            return;
798        }
799        let visible = viewport as usize;
800        let offset = self.list_state.offset();
801        if selected < offset {
802            *self.list_state.offset_mut() = selected;
803        } else if selected >= offset + visible {
804            *self.list_state.offset_mut() = selected + 1 - visible;
805        }
806    }
807
808    pub fn apply_search(&mut self, query: &str) {
809        let preferred = self.current_selection();
810        self.apply_search_with_preference(query, preferred.clone());
811    }
812
813    pub fn apply_search_with_preference(
814        &mut self,
815        query: &str,
816        preferred: Option<InlineListSelection>,
817    ) {
818        let trimmed = query.trim();
819        if trimmed.is_empty() {
820            if self.filter_query.is_none() {
821                if preferred.is_some() && self.current_selection() != preferred {
822                    self.select_initial(preferred);
823                }
824                return;
825            }
826            self.visible_indices = (0..self.items.len()).collect();
827            self.filter_terms.clear();
828            self.filter_query = None;
829            self.select_initial(preferred);
830            return;
831        }
832
833        if self.filter_query.as_deref() == Some(trimmed) {
834            if preferred.is_some() && self.current_selection() != preferred {
835                self.select_initial(preferred);
836            }
837            return;
838        }
839
840        let normalized_query = normalize_query(trimmed);
841        let terms = normalized_query
842            .split_whitespace()
843            .filter(|term| !term.is_empty())
844            .map(|term| term.to_owned())
845            .collect::<Vec<_>>();
846        let mut indices = Vec::new();
847        let mut pending_divider: Option<usize> = None;
848        let mut current_header: Option<usize> = None;
849        let mut header_matches = false;
850        let mut header_included = false;
851
852        for (index, item) in self.items.iter().enumerate() {
853            if item.is_divider {
854                pending_divider = Some(index);
855                current_header = None;
856                header_matches = false;
857                header_included = false;
858                continue;
859            }
860
861            if item.is_header() {
862                current_header = Some(index);
863                header_matches = item.matches(&normalized_query);
864                header_included = false;
865                if header_matches {
866                    if let Some(divider_index) = pending_divider.take() {
867                        indices.push(divider_index);
868                    }
869                    indices.push(index);
870                    header_included = true;
871                }
872                continue;
873            }
874
875            let item_matches = item.matches(&normalized_query);
876            let include_item = header_matches || item_matches;
877            if include_item {
878                if let Some(divider_index) = pending_divider.take() {
879                    indices.push(divider_index);
880                }
881                if let Some(header_index) = current_header
882                    && !header_included
883                {
884                    indices.push(header_index);
885                    header_included = true;
886                }
887                indices.push(index);
888            }
889        }
890        self.visible_indices = indices;
891        self.filter_terms = terms;
892        self.filter_query = Some(trimmed.to_owned());
893        self.select_initial(preferred);
894    }
895
896    fn select_initial(&mut self, preferred: Option<InlineListSelection>) {
897        let mut selection_index = preferred.and_then(|needle| {
898            self.visible_indices
899                .iter()
900                .position(|&idx| self.items[idx].selection.as_ref() == Some(&needle))
901        });
902
903        if selection_index.is_none() {
904            selection_index = self.first_selectable_index();
905        }
906
907        self.list_state.select(selection_index);
908        *self.list_state.offset_mut() = 0;
909    }
910
911    fn first_selectable_index(&self) -> Option<usize> {
912        self.visible_indices
913            .iter()
914            .position(|&idx| self.items[idx].selection.is_some())
915    }
916
917    fn last_selectable_index(&self) -> Option<usize> {
918        self.visible_indices
919            .iter()
920            .rposition(|&idx| self.items[idx].selection.is_some())
921    }
922
923    pub(super) fn filter_active(&self) -> bool {
924        self.filter_query
925            .as_ref()
926            .is_some_and(|value| !value.is_empty())
927    }
928
929    #[cfg(test)]
930    pub(super) fn filter_query(&self) -> Option<&str> {
931        self.filter_query.as_deref()
932    }
933
934    pub(super) fn highlight_terms(&self) -> &[String] {
935        &self.filter_terms
936    }
937
938    pub(super) fn visible_selectable_count(&self) -> usize {
939        self.visible_indices
940            .iter()
941            .filter(|&&idx| self.items[idx].selection.is_some())
942            .count()
943    }
944
945    pub(super) fn total_selectable(&self) -> usize {
946        self.total_selectable
947    }
948
949    pub(super) fn compact_rows(&self) -> bool {
950        self.compact_rows
951    }
952
953    pub(super) fn supports_density_toggle(&self) -> bool {
954        matches!(self.density_behavior, ModalListDensityBehavior::Adjustable)
955    }
956
957    pub(super) fn non_filter_summary_text(&self, footer_hint: Option<&str>) -> Option<String> {
958        if !self.has_non_filter_summary(footer_hint) {
959            return None;
960        }
961        match self.density_behavior {
962            ModalListDensityBehavior::FixedComfortable => {
963                Some(CONFIG_LIST_NAVIGATION_HINT.to_owned())
964            }
965            ModalListDensityBehavior::Adjustable => footer_hint
966                .filter(|hint| !hint.is_empty())
967                .map(ToOwned::to_owned),
968        }
969    }
970
971    pub(crate) fn summary_line_rows(&self, footer_hint: Option<&str>) -> usize {
972        if self.filter_active() || self.has_non_filter_summary(footer_hint) {
973            1
974        } else {
975            0
976        }
977    }
978
979    fn has_non_filter_summary(&self, footer_hint: Option<&str>) -> bool {
980        match self.density_behavior {
981            ModalListDensityBehavior::FixedComfortable => true,
982            ModalListDensityBehavior::Adjustable => {
983                footer_hint.is_some_and(|hint| !hint.is_empty())
984            }
985        }
986    }
987
988    pub fn toggle_row_density(&mut self) {
989        self.compact_rows = !self.compact_rows;
990    }
991
992    fn page_step(&self) -> usize {
993        let rows = self.viewport_rows.unwrap_or(0).max(1);
994        usize::from(rows)
995    }
996}
997
998#[allow(dead_code)]
999impl WizardModalState {
1000    /// Create a new wizard modal state from wizard steps
1001    pub fn new(
1002        title: String,
1003        steps: Vec<WizardStep>,
1004        current_step: usize,
1005        search: Option<InlineListSearchConfig>,
1006        mode: WizardModalMode,
1007    ) -> Self {
1008        let step_states: Vec<WizardStepState> = steps
1009            .into_iter()
1010            .map(|step| {
1011                let notes_active = step
1012                    .items
1013                    .first()
1014                    .and_then(|item| item.selection.as_ref())
1015                    .is_some_and(|selection| match selection {
1016                        InlineListSelection::RequestUserInputAnswer {
1017                            selected, other, ..
1018                        } => selected.is_empty() && other.is_some(),
1019                        _ => false,
1020                    });
1021                WizardStepState {
1022                    title: step.title,
1023                    question: step.question,
1024                    list: ModalListState::new(step.items, step.answer.clone()),
1025                    completed: step.completed,
1026                    answer: step.answer,
1027                    notes: String::new(),
1028                    notes_active,
1029                    allow_freeform: step.allow_freeform,
1030                    freeform_label: step.freeform_label,
1031                    freeform_placeholder: step.freeform_placeholder,
1032                }
1033            })
1034            .collect();
1035
1036        let clamped_step = if step_states.is_empty() {
1037            0
1038        } else {
1039            current_step.min(step_states.len().saturating_sub(1))
1040        };
1041
1042        Self {
1043            title,
1044            steps: step_states,
1045            current_step: clamped_step,
1046            search: search.map(ModalSearchState::from),
1047            mode,
1048        }
1049    }
1050
1051    /// Handle key event for wizard navigation
1052    pub fn handle_key_event(
1053        &mut self,
1054        key: &KeyEvent,
1055        modifiers: ModalKeyModifiers,
1056    ) -> ModalListKeyResult {
1057        if let Some(step) = self.steps.get_mut(self.current_step)
1058            && step.notes_active
1059        {
1060            match key.code {
1061                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1062                    step.notes.push(ch);
1063                    return ModalListKeyResult::Redraw;
1064                }
1065                KeyCode::Backspace => {
1066                    if step.notes.pop().is_some() {
1067                        return ModalListKeyResult::Redraw;
1068                    }
1069                    return ModalListKeyResult::HandledNoRedraw;
1070                }
1071                KeyCode::Tab | KeyCode::Esc => {
1072                    if !step.notes.is_empty() {
1073                        step.notes.clear();
1074                    }
1075                    step.notes_active = false;
1076                    return ModalListKeyResult::Redraw;
1077                }
1078                _ => {}
1079            }
1080        }
1081
1082        if let Some(step) = self.steps.get_mut(self.current_step)
1083            && !step.notes_active
1084            && Self::step_selected_custom_note_item_index(step).is_some()
1085        {
1086            match key.code {
1087                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1088                    step.notes_active = true;
1089                    step.notes.push(ch);
1090                    return ModalListKeyResult::Redraw;
1091                }
1092                KeyCode::Backspace => {
1093                    if step.notes.pop().is_some() {
1094                        step.notes_active = true;
1095                        return ModalListKeyResult::Redraw;
1096                    }
1097                }
1098                _ => {}
1099            }
1100        }
1101
1102        // Search handling (if enabled)
1103        if let Some(search) = self.search.as_mut()
1104            && let Some(step) = self.steps.get_mut(self.current_step)
1105        {
1106            match key.code {
1107                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1108                    search.push_char(ch);
1109                    step.list.apply_search(&search.query);
1110                    return ModalListKeyResult::Redraw;
1111                }
1112                KeyCode::Backspace => {
1113                    if search.backspace() {
1114                        step.list.apply_search(&search.query);
1115                        return ModalListKeyResult::Redraw;
1116                    }
1117                    return ModalListKeyResult::HandledNoRedraw;
1118                }
1119                KeyCode::Delete => {
1120                    if search.clear() {
1121                        step.list.apply_search(&search.query);
1122                        return ModalListKeyResult::Redraw;
1123                    }
1124                    return ModalListKeyResult::HandledNoRedraw;
1125                }
1126                KeyCode::Tab => {
1127                    if let Some(best_match) = step.list.get_best_matching_item(&search.query) {
1128                        search.query = best_match;
1129                        step.list.apply_search(&search.query);
1130                        return ModalListKeyResult::Redraw;
1131                    }
1132                    return ModalListKeyResult::HandledNoRedraw;
1133                }
1134                KeyCode::Esc => {
1135                    if search.clear() {
1136                        step.list.apply_search(&search.query);
1137                        return ModalListKeyResult::Redraw;
1138                    }
1139                }
1140                _ => {}
1141            }
1142        }
1143
1144        if self.mode == WizardModalMode::MultiStep
1145            && !modifiers.control
1146            && !modifiers.alt
1147            && !modifiers.command
1148            && self.search.is_none()
1149            && let KeyCode::Char(ch) = key.code
1150            && ch.is_ascii_digit()
1151            && ch != '0'
1152        {
1153            let target_index = ch.to_digit(10).unwrap_or(1).saturating_sub(1) as usize;
1154            if let Some(step) = self.steps.get_mut(self.current_step)
1155                && step.list.select_nth_selectable(target_index)
1156            {
1157                return self.submit_current_selection();
1158            }
1159            return ModalListKeyResult::HandledNoRedraw;
1160        }
1161
1162        match key.code {
1163            KeyCode::Char('n') | KeyCode::Char('N')
1164                if modifiers.control && self.mode == WizardModalMode::MultiStep =>
1165            {
1166                if self.current_step < self.steps.len().saturating_sub(1) {
1167                    self.current_step += 1;
1168                    ModalListKeyResult::Redraw
1169                } else {
1170                    ModalListKeyResult::HandledNoRedraw
1171                }
1172            }
1173            // Left arrow: go to previous step if available
1174            KeyCode::Left => {
1175                if self.current_step > 0 {
1176                    self.current_step -= 1;
1177                    ModalListKeyResult::Redraw
1178                } else {
1179                    ModalListKeyResult::HandledNoRedraw
1180                }
1181            }
1182            // Right arrow: go to next step if current is completed
1183            KeyCode::Right => {
1184                let can_advance = match self.mode {
1185                    WizardModalMode::MultiStep => self.current_step_completed(),
1186                    WizardModalMode::TabbedList => true,
1187                };
1188
1189                if can_advance && self.current_step < self.steps.len().saturating_sub(1) {
1190                    self.current_step += 1;
1191                    ModalListKeyResult::Redraw
1192                } else {
1193                    ModalListKeyResult::HandledNoRedraw
1194                }
1195            }
1196            // Enter: select current item and mark step complete
1197            KeyCode::Enter => self.submit_current_selection(),
1198            // Escape: cancel wizard
1199            KeyCode::Esc => {
1200                ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
1201            }
1202            // Up/Down/Tab: delegate to current step's list
1203            KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => {
1204                if let Some(step) = self.steps.get_mut(self.current_step) {
1205                    match key.code {
1206                        KeyCode::Up => {
1207                            if modifiers.command {
1208                                step.list.select_first();
1209                            } else {
1210                                step.list.select_previous();
1211                            }
1212                            ModalListKeyResult::Redraw
1213                        }
1214                        KeyCode::Down => {
1215                            if modifiers.command {
1216                                step.list.select_last();
1217                            } else {
1218                                step.list.select_next();
1219                            }
1220                            ModalListKeyResult::Redraw
1221                        }
1222                        KeyCode::Tab => {
1223                            if self.search.is_none()
1224                                && (step.allow_freeform
1225                                    || Self::step_selected_custom_note_item_index(step).is_some())
1226                            {
1227                                step.notes_active = !step.notes_active;
1228                                ModalListKeyResult::Redraw
1229                            } else {
1230                                step.list.select_next();
1231                                ModalListKeyResult::Redraw
1232                            }
1233                        }
1234                        KeyCode::BackTab => {
1235                            step.list.select_previous();
1236                            ModalListKeyResult::Redraw
1237                        }
1238                        _ => ModalListKeyResult::NotHandled,
1239                    }
1240                } else {
1241                    ModalListKeyResult::NotHandled
1242                }
1243            }
1244            _ => ModalListKeyResult::NotHandled,
1245        }
1246    }
1247
1248    pub fn handle_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
1249        let Some(step) = self.steps.get_mut(self.current_step) else {
1250            return ModalListKeyResult::NotHandled;
1251        };
1252        let Some(&item_index) = step.list.visible_indices.get(visible_index) else {
1253            return ModalListKeyResult::HandledNoRedraw;
1254        };
1255        if step
1256            .list
1257            .items
1258            .get(item_index)
1259            .and_then(|item| item.selection.as_ref())
1260            .is_none()
1261        {
1262            return ModalListKeyResult::HandledNoRedraw;
1263        }
1264
1265        if step.list.list_state.selected() == Some(visible_index) {
1266            return self.submit_current_selection();
1267        }
1268
1269        step.list.list_state.select(Some(visible_index));
1270        if let Some(rows) = step.list.viewport_rows {
1271            step.list.ensure_visible(rows);
1272        }
1273        ModalListKeyResult::Redraw
1274    }
1275
1276    pub fn handle_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
1277        let Some(step) = self.steps.get_mut(self.current_step) else {
1278            return ModalListKeyResult::NotHandled;
1279        };
1280
1281        let before = step.list.list_state.selected();
1282        if down {
1283            step.list.select_next();
1284        } else {
1285            step.list.select_previous();
1286        }
1287
1288        if step.list.list_state.selected() == before {
1289            ModalListKeyResult::HandledNoRedraw
1290        } else {
1291            ModalListKeyResult::Redraw
1292        }
1293    }
1294
1295    /// Get current selection from the active step
1296    fn current_selection(&self) -> Option<InlineListSelection> {
1297        self.steps
1298            .get(self.current_step)
1299            .and_then(|step| {
1300                step.list
1301                    .current_selection()
1302                    .map(|selection| (selection, step))
1303            })
1304            .map(|(selection, step)| match selection {
1305                InlineListSelection::RequestUserInputAnswer {
1306                    question_id,
1307                    selected,
1308                    other,
1309                } => {
1310                    let notes = step.notes.trim();
1311                    let next_other = if other.is_some() {
1312                        Some(notes.to_string())
1313                    } else if notes.is_empty() {
1314                        None
1315                    } else {
1316                        Some(notes.to_string())
1317                    };
1318                    InlineListSelection::RequestUserInputAnswer {
1319                        question_id,
1320                        selected,
1321                        other: next_other,
1322                    }
1323                }
1324                InlineListSelection::AskUserChoice {
1325                    tab_id, choice_id, ..
1326                } => {
1327                    let notes = step.notes.trim();
1328                    let text = if notes.is_empty() {
1329                        None
1330                    } else {
1331                        Some(notes.to_string())
1332                    };
1333                    InlineListSelection::AskUserChoice {
1334                        tab_id,
1335                        choice_id,
1336                        text,
1337                    }
1338                }
1339                _ => selection,
1340            })
1341    }
1342
1343    /// Check if current step is completed
1344    fn current_step_completed(&self) -> bool {
1345        self.steps
1346            .get(self.current_step)
1347            .is_some_and(|step| step.completed)
1348    }
1349
1350    fn step_selected_custom_note_item_index(step: &WizardStepState) -> Option<usize> {
1351        let selected_visible = step.list.list_state.selected()?;
1352        let item_index = *step.list.visible_indices.get(selected_visible)?;
1353        let item = step.list.items.get(item_index)?;
1354        match item.selection.as_ref() {
1355            Some(InlineListSelection::RequestUserInputAnswer {
1356                selected, other, ..
1357            }) if selected.is_empty() && other.is_some() => Some(item_index),
1358            _ => None,
1359        }
1360    }
1361
1362    fn current_step_selected_custom_note_item_index(&self) -> Option<usize> {
1363        self.steps
1364            .get(self.current_step)
1365            .and_then(Self::step_selected_custom_note_item_index)
1366    }
1367
1368    fn current_step_requires_custom_note_input(&self) -> bool {
1369        self.current_step_selected_custom_note_item_index()
1370            .is_some()
1371    }
1372
1373    fn current_step_supports_notes(&self) -> bool {
1374        self.steps
1375            .get(self.current_step)
1376            .and_then(|step| step.list.current_selection())
1377            .is_some_and(|selection| {
1378                matches!(
1379                    selection,
1380                    InlineListSelection::RequestUserInputAnswer { .. }
1381                        | InlineListSelection::AskUserChoice { .. }
1382                )
1383            })
1384    }
1385
1386    pub fn unanswered_count(&self) -> usize {
1387        self.steps.iter().filter(|step| !step.completed).count()
1388    }
1389
1390    pub fn question_header(&self) -> String {
1391        format!(
1392            "Question {}/{} ({} unanswered)",
1393            self.current_step.saturating_add(1),
1394            self.steps.len(),
1395            self.unanswered_count()
1396        )
1397    }
1398
1399    pub fn notes_line(&self) -> Option<String> {
1400        let step = self.steps.get(self.current_step)?;
1401        if step.notes_active || !step.notes.is_empty() {
1402            let label = step.freeform_label.as_deref().unwrap_or("›");
1403            if step.notes.is_empty()
1404                && let Some(placeholder) = step.freeform_placeholder.as_ref()
1405            {
1406                return Some(format!("{} {}", label, placeholder));
1407            }
1408            Some(format!("{} {}", label, step.notes))
1409        } else {
1410            None
1411        }
1412    }
1413
1414    pub fn notes_active(&self) -> bool {
1415        self.steps
1416            .get(self.current_step)
1417            .is_some_and(|step| step.notes_active)
1418    }
1419
1420    pub fn instruction_lines(&self) -> Vec<String> {
1421        let step = match self.steps.get(self.current_step) {
1422            Some(s) => s,
1423            None => return Vec::new(),
1424        };
1425        let custom_note_selected = self.current_step_requires_custom_note_input();
1426
1427        if self.notes_active() {
1428            if custom_note_selected {
1429                vec!["type custom note | enter to continue | esc to clear".to_string()]
1430            } else {
1431                vec!["tab or esc to clear notes | enter to submit answer".to_string()]
1432            }
1433        } else {
1434            let mut lines = Vec::new();
1435            if custom_note_selected {
1436                lines.push("type custom note | enter to continue".to_string());
1437            } else if step.allow_freeform {
1438                lines.push("tab to add notes | enter to submit answer".to_string());
1439            } else {
1440                lines.push("enter to submit answer".to_string());
1441            }
1442            lines.push("ctrl + n next question | esc to interrupt".to_string());
1443            lines
1444        }
1445    }
1446
1447    /// Mark current step as completed with the given answer
1448    fn complete_current_step(&mut self, answer: InlineListSelection) {
1449        if let Some(step) = self.steps.get_mut(self.current_step) {
1450            step.completed = true;
1451            step.answer = Some(answer);
1452        }
1453    }
1454
1455    /// Collect all answers from completed steps
1456    fn collect_answers(&self) -> Vec<InlineListSelection> {
1457        self.steps
1458            .iter()
1459            .filter_map(|step| step.answer.clone())
1460            .collect()
1461    }
1462
1463    fn submit_current_selection(&mut self) -> ModalListKeyResult {
1464        if self.current_step_requires_custom_note_input()
1465            && let Some(step) = self.steps.get_mut(self.current_step)
1466            && step.notes.trim().is_empty()
1467        {
1468            step.notes_active = true;
1469            return ModalListKeyResult::Redraw;
1470        }
1471
1472        let Some(selection) = self.current_selection() else {
1473            return ModalListKeyResult::HandledNoRedraw;
1474        };
1475
1476        match self.mode {
1477            WizardModalMode::TabbedList => ModalListKeyResult::Submit(InlineEvent::Overlay(
1478                OverlayEvent::Submitted(OverlaySubmission::Wizard(vec![selection])),
1479            )),
1480            WizardModalMode::MultiStep => {
1481                self.complete_current_step(selection.clone());
1482                if self.current_step < self.steps.len().saturating_sub(1) {
1483                    self.current_step += 1;
1484                    ModalListKeyResult::Redraw
1485                } else {
1486                    ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
1487                        OverlaySubmission::Wizard(self.collect_answers()),
1488                    )))
1489                }
1490            }
1491        }
1492    }
1493
1494    /// Check if all steps are completed
1495    pub fn all_steps_completed(&self) -> bool {
1496        self.steps.iter().all(|step| step.completed)
1497    }
1498}