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