Skip to main content

vtcode_tui/core_tui/session/modal/
state.rs

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