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