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