Skip to main content

thndrs_lib/cli/app/
input.rs

1//! Keyboard interaction, prompt accessories, and picker behavior.
2//!
3//! This module converts terminal key and mouse events into prompt edits,
4//! accessory transitions, picker selections, and submitted [`Msg`] values. It
5//! owns command mode, file/model/reasoning/skill pickers, detail-pane
6//! navigation, input history, and queued input while an agent is running.
7use super::*;
8
9/// Top-level interaction mode.
10#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
11pub enum Mode {
12    /// Normal prompt entry.
13    #[default]
14    Prompt,
15    /// Slash-command entry, entered with `:`.
16    Command,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
20pub enum PromptAccessory {
21    #[default]
22    None,
23    Help,
24    Commands {
25        selected: usize,
26    },
27    Files(FilePickerSource),
28    Models,
29    ReasoningEffort,
30    Skills,
31    /// Bounded inspection of the current context ledger.
32    Context,
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub enum FilePickerSource {
37    Forced,
38    Mention { token_start: usize },
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum KeyOutcome {
43    Unhandled,
44    Handled,
45    Followup(Msg),
46}
47
48impl KeyOutcome {
49    fn with(followup: Option<Msg>) -> Self {
50        match followup {
51            Some(msg) => Self::Followup(msg),
52            None => Self::Handled,
53        }
54    }
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct PickerItem {
59    pub label: String,
60    pub detail: String,
61}
62
63impl PickerItem {
64    pub fn new(label: impl Into<String>, detail: impl Into<String>) -> Self {
65        Self { label: label.into(), detail: detail.into() }
66    }
67
68    fn searchable(&self) -> String {
69        if self.detail.is_empty() { self.label.clone() } else { format!("{} {}", self.label, self.detail) }
70    }
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct PickerState {
75    pub query: String,
76    pub all_items: Vec<PickerItem>,
77    pub matches: Vec<PickerItem>,
78    /// Character indices of fuzzy match highlights, parallel to `matches`.
79    pub match_indices: Vec<Vec<usize>>,
80    pub selected: usize,
81    pub scroll: usize,
82    limit: usize,
83}
84
85impl PickerState {
86    pub fn new(all_items: Vec<PickerItem>, limit: usize) -> Self {
87        let (matches, match_indices) = split_filter_items(&all_items, "", limit);
88        Self { query: String::new(), all_items, matches, match_indices, selected: 0, scroll: 0, limit }
89    }
90
91    pub fn refresh_matches(&mut self) {
92        let (matches, match_indices) = split_filter_items(&self.all_items, &self.query, self.limit);
93        self.matches = matches;
94        self.match_indices = match_indices;
95        self.selected = self.selected.min(self.matches.len().saturating_sub(1));
96        self.ensure_selected_visible();
97    }
98
99    fn move_up(&mut self) {
100        if self.matches.is_empty() {
101            return;
102        }
103        self.selected = self.selected.saturating_sub(1);
104        self.ensure_selected_visible();
105    }
106
107    fn move_down(&mut self) {
108        if self.matches.is_empty() {
109            return;
110        }
111        self.selected = (self.selected + 1).min(self.matches.len().saturating_sub(1));
112        self.ensure_selected_visible();
113    }
114
115    fn page_up(&mut self) {
116        self.selected = self.selected.saturating_sub(VISIBLE_ROWS);
117        self.ensure_selected_visible();
118    }
119
120    fn page_down(&mut self) {
121        if self.matches.is_empty() {
122            return;
123        }
124        self.selected = (self.selected + VISIBLE_ROWS).min(self.matches.len().saturating_sub(1));
125        self.ensure_selected_visible();
126    }
127
128    pub fn selected(&self) -> Option<&PickerItem> {
129        self.matches.get(self.selected)
130    }
131
132    fn ensure_selected_visible(&mut self) {
133        if self.selected < self.scroll {
134            self.scroll = self.selected;
135        } else if self.selected >= self.scroll + VISIBLE_ROWS {
136            self.scroll = self.selected.saturating_sub(VISIBLE_ROWS - 1);
137        }
138    }
139}
140
141/// - Ctrl+C cancels a running agent stream, otherwise quits.
142/// - Ctrl+D requires a double-press: the first press shows a confirmation
143///   message; the second press within roughly three seconds quits. Any other
144///   key (or timeout) cancels the pending state.
145/// - Printable characters append to the input buffer.
146/// - Backspace removes the last character.
147/// - `Enter` submits: slash commands (`/clear`, `/quit`) are routed, otherwise
148///   the input is appended as [`Entry::User`] and cleared.
149/// - Escape cancels an active agent stream.
150/// - Up/Down recall prompt history.
151pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
152    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
153        if app.run_state == RunState::Working {
154            agent_lifecycle::cancel_stream(app);
155            return None;
156        }
157        app.quit = true;
158        return Some(Msg::Quit);
159    }
160
161    if key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL) {
162        open_detail_surface(app);
163        return None;
164    }
165
166    if key.code == KeyCode::Char('d')
167        && key.modifiers.contains(KeyModifiers::CONTROL)
168        && !key.modifiers.contains(KeyModifiers::ALT)
169    {
170        if let Some(deadline) = app.ctrl_d_pending
171            && !agent_lifecycle::now_or_after_deadline(app.ui_tick, deadline)
172        {
173            app.ctrl_d_pending = None;
174            app.quit = true;
175            return Some(Msg::Quit);
176        } else {
177            let deadline = app.ui_tick.wrapping_add(quit_confirm_timeout_ticks(app));
178            app.ctrl_d_pending = Some(deadline);
179            app.transcript
180                .push(Entry::Status { text: String::from("Press CTRL+D again to quit.") });
181            return None;
182        }
183    }
184
185    if key.code == KeyCode::Char('t')
186        && key.modifiers.contains(KeyModifiers::CONTROL)
187        && app.run_state == RunState::Working
188    {
189        app.queue_target = app.queue_target.toggle();
190        app.transcript
191            .push(Entry::Status { text: format!("queue target: {}", app.queue_target.label()) });
192        return None;
193    }
194
195    app.ctrl_d_pending = None;
196
197    if app.pending_permission.is_some() {
198        return agent_lifecycle::handle_permission_key(app, key);
199    }
200
201    if app.first_run_recovery.is_some() {
202        return handle_first_run_key(app, key);
203    }
204
205    if app.detail_pane.open {
206        return handle_detail_pane_key(app, key);
207    }
208
209    if !matches!(app.prompt_accessory, PromptAccessory::None) {
210        match handle_accessory_key(app, key) {
211            KeyOutcome::Unhandled => {}
212            KeyOutcome::Handled => return None,
213            KeyOutcome::Followup(msg) => return Some(msg),
214        }
215    }
216
217    match app.mode {
218        Mode::Command => handle_command_key(app, key),
219        Mode::Prompt => handle_prompt_key(app, key),
220    }
221}
222
223pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> Option<Msg> {
224    if app.first_run_recovery.is_some() {
225        return None;
226    }
227
228    match app.prompt_accessory {
229        PromptAccessory::Files(_)
230        | PromptAccessory::Models
231        | PromptAccessory::ReasoningEffort
232        | PromptAccessory::Skills
233        | PromptAccessory::Context => {
234            if let Some(picker) = app.picker.as_mut() {
235                match mouse.kind {
236                    MouseEventKind::ScrollUp => picker.move_up(),
237                    MouseEventKind::ScrollDown => picker.move_down(),
238                    _ => {}
239                }
240            }
241            None
242        }
243        _ => None,
244    }
245}
246
247pub fn handle_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
248    match app.prompt_accessory {
249        PromptAccessory::Help => match key.code {
250            KeyCode::Esc => {
251                close_prompt_accessory(app);
252                KeyOutcome::Handled
253            }
254            _ => KeyOutcome::Unhandled,
255        },
256        PromptAccessory::Commands { .. } => handle_command_accessory_key(app, key),
257        PromptAccessory::Files(_) => handle_file_accessory_key(app, key),
258        PromptAccessory::Models => handle_model_accessory_key(app, key),
259        PromptAccessory::ReasoningEffort => handle_reasoning_effort_accessory_key(app, key),
260        PromptAccessory::Skills => handle_skill_accessory_key(app, key),
261        PromptAccessory::Context => match key.code {
262            KeyCode::Esc => {
263                close_prompt_accessory(app);
264                KeyOutcome::Handled
265            }
266            _ => KeyOutcome::Unhandled,
267        },
268        PromptAccessory::None => KeyOutcome::Unhandled,
269    }
270}
271
272pub fn handle_command_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
273    let count = command_suggestions_for_app(app).len();
274    match key.code {
275        KeyCode::Esc => {
276            close_prompt_accessory(app);
277            KeyOutcome::Handled
278        }
279        KeyCode::Up => {
280            if let PromptAccessory::Commands { selected } = &mut app.prompt_accessory {
281                *selected = selected.saturating_sub(1);
282            }
283            KeyOutcome::Handled
284        }
285        KeyCode::Down => {
286            if let PromptAccessory::Commands { selected } = &mut app.prompt_accessory {
287                *selected = (*selected + 1).min(count.saturating_sub(1));
288            }
289            KeyOutcome::Handled
290        }
291        KeyCode::Enter
292            if count > 0
293                && !command_suggestions_for_app(app)
294                    .iter()
295                    .any(|(cmd, _)| *cmd == command_query(app)) =>
296        {
297            KeyOutcome::with(accept_command_suggestion(app))
298        }
299        _ => KeyOutcome::Unhandled,
300    }
301}
302
303pub fn handle_file_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
304    let source = match app.prompt_accessory {
305        PromptAccessory::Files(source) => source,
306        _ => return KeyOutcome::Unhandled,
307    };
308    let Some(picker) = app.picker.as_mut() else {
309        return KeyOutcome::Unhandled;
310    };
311    match key.code {
312        KeyCode::Esc => {
313            close_prompt_accessory(app);
314            KeyOutcome::Handled
315        }
316        KeyCode::Enter => {
317            accept_file_suggestion(app);
318            KeyOutcome::Handled
319        }
320        KeyCode::Up => {
321            picker.move_up();
322            KeyOutcome::Handled
323        }
324        KeyCode::Down => {
325            picker.move_down();
326            KeyOutcome::Handled
327        }
328        KeyCode::PageUp => {
329            picker.page_up();
330            KeyOutcome::Handled
331        }
332        KeyCode::PageDown => {
333            picker.page_down();
334            KeyOutcome::Handled
335        }
336        KeyCode::Backspace if source == FilePickerSource::Forced => {
337            picker.query.pop();
338            picker.refresh_matches();
339            KeyOutcome::Handled
340        }
341        KeyCode::Char(ch) if source == FilePickerSource::Forced => {
342            picker.query.push(ch);
343            picker.refresh_matches();
344            KeyOutcome::Handled
345        }
346        _ => KeyOutcome::Unhandled,
347    }
348}
349
350pub fn handle_model_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
351    let Some(picker) = app.picker.as_mut() else {
352        return KeyOutcome::Unhandled;
353    };
354    match key.code {
355        KeyCode::Esc => {
356            close_prompt_accessory(app);
357            KeyOutcome::Handled
358        }
359        KeyCode::Enter => {
360            accept_model_suggestion(app);
361            KeyOutcome::Handled
362        }
363        KeyCode::Up => {
364            picker.move_up();
365            KeyOutcome::Handled
366        }
367        KeyCode::Down => {
368            picker.move_down();
369            KeyOutcome::Handled
370        }
371        KeyCode::PageUp => {
372            picker.page_up();
373            KeyOutcome::Handled
374        }
375        KeyCode::PageDown => {
376            picker.page_down();
377            KeyOutcome::Handled
378        }
379        KeyCode::Backspace => {
380            picker.query.pop();
381            picker.refresh_matches();
382            KeyOutcome::Handled
383        }
384        KeyCode::Char(ch) => {
385            picker.query.push(ch);
386            picker.refresh_matches();
387            KeyOutcome::Handled
388        }
389        _ => KeyOutcome::Unhandled,
390    }
391}
392
393pub fn handle_reasoning_effort_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
394    let Some(picker) = app.picker.as_mut() else {
395        return KeyOutcome::Unhandled;
396    };
397    match key.code {
398        KeyCode::Esc => {
399            finish_reasoning_effort_picker(app);
400            KeyOutcome::Handled
401        }
402        KeyCode::Enter => {
403            accept_reasoning_effort_suggestion(app);
404            KeyOutcome::Handled
405        }
406        KeyCode::Up => {
407            picker.move_up();
408            KeyOutcome::Handled
409        }
410        KeyCode::Down => {
411            picker.move_down();
412            KeyOutcome::Handled
413        }
414        KeyCode::PageUp => {
415            picker.page_up();
416            KeyOutcome::Handled
417        }
418        KeyCode::PageDown => {
419            picker.page_down();
420            KeyOutcome::Handled
421        }
422        KeyCode::Backspace => {
423            picker.query.pop();
424            picker.refresh_matches();
425            KeyOutcome::Handled
426        }
427        KeyCode::Char(ch) => {
428            picker.query.push(ch);
429            picker.refresh_matches();
430            KeyOutcome::Handled
431        }
432        _ => KeyOutcome::Unhandled,
433    }
434}
435
436pub fn handle_skill_accessory_key(app: &mut App, key: KeyEvent) -> KeyOutcome {
437    let Some(picker) = app.picker.as_mut() else {
438        return KeyOutcome::Unhandled;
439    };
440    match key.code {
441        KeyCode::Esc => {
442            close_prompt_accessory(app);
443            KeyOutcome::Handled
444        }
445        KeyCode::Enter => {
446            accept_skill_suggestion(app);
447            KeyOutcome::Handled
448        }
449        KeyCode::Up => {
450            picker.move_up();
451            KeyOutcome::Handled
452        }
453        KeyCode::Down => {
454            picker.move_down();
455            KeyOutcome::Handled
456        }
457        KeyCode::PageUp => {
458            picker.page_up();
459            KeyOutcome::Handled
460        }
461        KeyCode::PageDown => {
462            picker.page_down();
463            KeyOutcome::Handled
464        }
465        KeyCode::Backspace => {
466            picker.query.pop();
467            picker.refresh_matches();
468            KeyOutcome::Handled
469        }
470        KeyCode::Char(ch) => {
471            picker.query.push(ch);
472            picker.refresh_matches();
473            KeyOutcome::Handled
474        }
475        _ => KeyOutcome::Unhandled,
476    }
477}
478
479/// Handle keys in Command mode: typed chars build the command buffer,
480/// Enter executes, Esc/Backspace-on-empty returns to Prompt.
481pub fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
482    match key.code {
483        KeyCode::Esc => {
484            app.mode = Mode::Prompt;
485            app.input.clear();
486            close_prompt_accessory(app);
487            None
488        }
489        KeyCode::Backspace => {
490            if app.input.is_empty() {
491                app.mode = Mode::Prompt;
492                close_prompt_accessory(app);
493            } else {
494                app.input.backspace();
495                sync_prompt_accessory(app);
496            }
497            None
498        }
499        KeyCode::Tab => {
500            accept_prompt_suggestion(app);
501            None
502        }
503        KeyCode::Enter => {
504            let text = app.input.as_str().trim().to_string();
505            app.input.clear();
506            app.mode = Mode::Prompt;
507            close_prompt_accessory(app);
508            if text.is_empty() { None } else { handle_command(app, &text) }
509        }
510        KeyCode::Char(ch) => {
511            app.input.insert_char(ch);
512            sync_prompt_accessory(app);
513            None
514        }
515        _ => None,
516    }
517}
518
519/// Handle keys in normal Prompt mode.
520///
521/// Cursor keybinds:
522/// - `left` / `ctrl+b`: move cursor left
523/// - `right` / `ctrl+f`: move cursor right
524/// - `alt+left` / `ctrl+left` / `alt+b`: move cursor word left
525/// - `alt+right` / `ctrl+right` / `alt+f`: move cursor word right
526/// - `home` / `ctrl+a`: move to line start
527/// - `end` / `ctrl+e`: move to line end
528/// - `shift+enter` / `ctrl+j`: insert newline
529/// - `backspace`: delete char before cursor
530/// - `delete`: delete char after cursor (forward delete)
531pub fn handle_prompt_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
532    if app.pending_permission.is_some() {
533        return None;
534    }
535
536    if key.modifiers.contains(KeyModifiers::ALT) {
537        let handled = match key.code {
538            KeyCode::Left | KeyCode::Char('b') => {
539                app.input.cursor_word_left();
540                agent_lifecycle::exit_history_navigation(app);
541                true
542            }
543            KeyCode::Right | KeyCode::Char('f') => {
544                app.input.cursor_word_right();
545                agent_lifecycle::exit_history_navigation(app);
546                true
547            }
548            KeyCode::Backspace => {
549                let killed = app.input.kill_word_left();
550                if !killed.is_empty() {
551                    app.kill_ring.push(killed);
552                }
553                agent_lifecycle::exit_history_navigation(app);
554                sync_prompt_accessory(app);
555                true
556            }
557            KeyCode::Char('d') => {
558                let killed = app.input.kill_word_right();
559                if !killed.is_empty() {
560                    app.kill_ring.push(killed);
561                }
562                agent_lifecycle::exit_history_navigation(app);
563                sync_prompt_accessory(app);
564                true
565            }
566            _ => false,
567        };
568        if handled {
569            return None;
570        }
571    }
572
573    if key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) {
574        let handled = match key.code {
575            KeyCode::Left => {
576                app.input.cursor_word_left();
577                agent_lifecycle::exit_history_navigation(app);
578                true
579            }
580            KeyCode::Right => {
581                app.input.cursor_word_right();
582                agent_lifecycle::exit_history_navigation(app);
583                true
584            }
585            _ => false,
586        };
587        if handled {
588            return None;
589        }
590    }
591
592    if key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) {
593        let handled = match key.code {
594            KeyCode::Char('a') => {
595                app.input.cursor_to_start();
596                agent_lifecycle::exit_history_navigation(app);
597                sync_prompt_accessory(app);
598                true
599            }
600            KeyCode::Char('e') => {
601                app.input.cursor_to_end();
602                agent_lifecycle::exit_history_navigation(app);
603                sync_prompt_accessory(app);
604                true
605            }
606            KeyCode::Char('b') => {
607                app.input.cursor_left();
608                agent_lifecycle::exit_history_navigation(app);
609                sync_prompt_accessory(app);
610                true
611            }
612            KeyCode::Char('f') => {
613                app.input.cursor_right();
614                agent_lifecycle::exit_history_navigation(app);
615                sync_prompt_accessory(app);
616                true
617            }
618            KeyCode::Char('j') => {
619                agent_lifecycle::exit_history_navigation(app);
620                app.input.insert_char('\n');
621                sync_prompt_accessory(app);
622                true
623            }
624            KeyCode::Char('k') => {
625                let killed = app.input.kill_to_end_of_line();
626                if !killed.is_empty() {
627                    app.kill_ring.push(killed);
628                }
629                agent_lifecycle::exit_history_navigation(app);
630                sync_prompt_accessory(app);
631                true
632            }
633            KeyCode::Char('u') => {
634                let killed = app.input.kill_to_start_of_line();
635                if !killed.is_empty() {
636                    app.kill_ring.push(killed);
637                }
638                agent_lifecycle::exit_history_navigation(app);
639                sync_prompt_accessory(app);
640                true
641            }
642            KeyCode::Char('w') => {
643                let killed = app.input.kill_word_left();
644                if !killed.is_empty() {
645                    app.kill_ring.push(killed);
646                }
647                agent_lifecycle::exit_history_navigation(app);
648                sync_prompt_accessory(app);
649                true
650            }
651            KeyCode::Char('y') => {
652                if let Some(killed) = app.kill_ring.last() {
653                    app.input.yank(killed);
654                }
655                agent_lifecycle::exit_history_navigation(app);
656                sync_prompt_accessory(app);
657                true
658            }
659            KeyCode::Char('t') => {
660                app.input.transpose_chars();
661                agent_lifecycle::exit_history_navigation(app);
662                sync_prompt_accessory(app);
663                true
664            }
665            _ => false,
666        };
667        if handled {
668            return None;
669        }
670    }
671
672    match key.code {
673        KeyCode::Char('?') if app.input.is_empty() => {
674            app.prompt_accessory = PromptAccessory::Help;
675            None
676        }
677        KeyCode::Char(':') if app.input.is_empty() && matches!(app.run_state, RunState::Idle | RunState::Error(_)) => {
678            app.mode = Mode::Command;
679            app.prompt_accessory = PromptAccessory::Commands { selected: 0 };
680            None
681        }
682        KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
683            agent_lifecycle::exit_history_navigation(app);
684            app.input.insert_char('\n');
685            sync_prompt_accessory(app);
686            None
687        }
688        KeyCode::Up => {
689            if !app.input.cursor_up() {
690                agent_lifecycle::recall_older_input(app);
691            } else {
692                agent_lifecycle::exit_history_navigation(app);
693            }
694            sync_prompt_accessory(app);
695            None
696        }
697        KeyCode::Down => {
698            if !app.input.cursor_down() {
699                agent_lifecycle::recall_newer_input(app);
700            } else {
701                agent_lifecycle::exit_history_navigation(app);
702            }
703            sync_prompt_accessory(app);
704            None
705        }
706        KeyCode::Left => {
707            app.input.cursor_left();
708            agent_lifecycle::exit_history_navigation(app);
709            sync_prompt_accessory(app);
710            None
711        }
712        KeyCode::Right => {
713            app.input.cursor_right();
714            agent_lifecycle::exit_history_navigation(app);
715            sync_prompt_accessory(app);
716            None
717        }
718        KeyCode::Home => {
719            app.input.cursor_to_start();
720            agent_lifecycle::exit_history_navigation(app);
721            sync_prompt_accessory(app);
722            None
723        }
724        KeyCode::End => {
725            app.input.cursor_to_end();
726            agent_lifecycle::exit_history_navigation(app);
727            sync_prompt_accessory(app);
728            None
729        }
730        KeyCode::PageUp | KeyCode::PageDown => None,
731        KeyCode::Delete => {
732            agent_lifecycle::exit_history_navigation(app);
733            app.input.delete_forward();
734            sync_prompt_accessory(app);
735            None
736        }
737        KeyCode::Char(ch) => {
738            agent_lifecycle::exit_history_navigation(app);
739            app.input.insert_char(ch);
740            sync_prompt_accessory(app);
741            None
742        }
743        KeyCode::Backspace => {
744            agent_lifecycle::exit_history_navigation(app);
745            app.input.backspace();
746            sync_prompt_accessory(app);
747            None
748        }
749        KeyCode::Enter => handle_submit(app),
750        KeyCode::Tab => accept_prompt_suggestion(app),
751        KeyCode::Esc if app.run_state == RunState::Working => {
752            agent_lifecycle::cancel_stream(app);
753            None
754        }
755        _ => None,
756    }
757}
758
759pub fn command_query(app: &App) -> String {
760    if app.mode == Mode::Command {
761        app.input.as_str().trim_start().to_string()
762    } else {
763        app.input
764            .as_str()
765            .strip_prefix('/')
766            .unwrap_or("")
767            .trim_start()
768            .to_string()
769    }
770}
771
772pub fn accept_command_suggestion(app: &mut App) -> Option<Msg> {
773    let suggestions = command_suggestions_for_app(app);
774    if suggestions.is_empty() {
775        return None;
776    }
777    let selected = match app.prompt_accessory {
778        PromptAccessory::Commands { selected } => selected.min(suggestions.len() - 1),
779        _ => 0,
780    };
781    let command = suggestions[selected].0;
782    let replacement = if app.mode == Mode::Command { format!("{command} ") } else { format!("/{command} ") };
783    app.input.set_text(&replacement);
784    app.prompt_accessory = PromptAccessory::None;
785    None
786}
787
788/// Accept the active prompt suggestion based on current accessory focus.
789///
790/// The prompt can surface command suggestions (`:` / slash-mode) and file
791/// mention suggestions (`@path`). This helper keeps the selection model
792/// centralized and safely no-ops when no suggestion is available.
793pub fn accept_prompt_suggestion(app: &mut App) -> Option<Msg> {
794    match app.prompt_accessory {
795        PromptAccessory::Commands { selected: _ } => accept_command_suggestion(app),
796        PromptAccessory::Files(_) => {
797            accept_file_suggestion(app);
798            None
799        }
800        PromptAccessory::None
801        | PromptAccessory::Help
802        | PromptAccessory::Models
803        | PromptAccessory::ReasoningEffort
804        | PromptAccessory::Skills
805        | PromptAccessory::Context => {
806            if app.mode == Mode::Command || app.input.as_str().starts_with('/') {
807                accept_command_suggestion(app)
808            } else {
809                None
810            }
811        }
812    }
813}
814
815pub fn open_file_picker(app: &mut App, source: FilePickerSource) {
816    match tools::searchable_file_paths(&app.cwd, 2_000) {
817        Ok(files) => {
818            let items = files.into_iter().map(|path| PickerItem::new(path, "")).collect();
819            app.picker = Some(PickerState::new(items, LARGE_PICKER_LIMIT));
820            app.prompt_accessory = PromptAccessory::Files(source);
821            sync_file_picker_query(app);
822        }
823        Err(err) => {
824            app.transcript
825                .push(Entry::Error { text: format!("file picker failed: {err}") });
826        }
827    }
828}
829
830pub fn open_model_picker(app: &mut App) {
831    let items = if app.model_picker_items.is_empty() {
832        offline_model_picker_items()
833    } else {
834        app.model_picker_items.clone()
835    }
836    .into_iter()
837    .filter(|item| provider_authenticated(provider_for_model(&item.label), &app.cwd))
838    .collect::<Vec<_>>();
839    if items.is_empty() {
840        app.transcript
841            .push(Entry::Status { text: String::from("no authenticated providers; run /login <provider> or /setup") });
842        return;
843    }
844    app.picker = Some(PickerState::new(items, MODEL_PICKER_LIMIT));
845    app.prompt_accessory = PromptAccessory::Models;
846}
847
848pub fn open_reasoning_effort_picker(app: &mut App) {
849    let options = crate::providers::reasoning_options(&app.model);
850    let items = options
851        .into_iter()
852        .map(|effort| {
853            PickerItem::new(
854                effort.label(),
855                format!("{} — {}", effort.display_label(), effort.description()),
856            )
857        })
858        .collect();
859    let mut picker = PickerState::new(items, MODEL_PICKER_LIMIT);
860    picker.selected = crate::providers::reasoning_options(&app.model)
861        .iter()
862        .position(|effort| *effort == app.cli.reasoning_effort)
863        .unwrap_or_default();
864    app.picker = Some(picker);
865    app.prompt_accessory = PromptAccessory::ReasoningEffort;
866}
867
868pub fn open_skill_picker(app: &mut App) {
869    for diagnostic in &app.skill_diagnostics {
870        app.transcript.push(Entry::Error { text: diagnostic.summary() });
871    }
872
873    if app.skills.is_empty() {
874        app.transcript
875            .push(Entry::Status { text: String::from("skills  none loaded") });
876        return;
877    }
878
879    let items = app
880        .skills
881        .iter()
882        .map(|skill| PickerItem::new(skill.name.clone(), skill.description.clone()))
883        .collect();
884    app.picker = Some(PickerState::new(items, LARGE_PICKER_LIMIT));
885    app.prompt_accessory = PromptAccessory::Skills;
886}
887
888pub fn offline_model_picker_items() -> Vec<PickerItem> {
889    umans::known_models()
890        .into_iter()
891        .map(|model| PickerItem::new(model.id, model.description))
892        .chain(
893            opencode::known_models()
894                .into_iter()
895                .map(|model| PickerItem::new(model.id, model.description)),
896        )
897        .chain(
898            codex::known_models()
899                .into_iter()
900                .map(|model| PickerItem::new(model.id, model.description)),
901        )
902        .collect()
903}
904
905pub fn close_prompt_accessory(app: &mut App) {
906    if matches!(
907        app.prompt_accessory,
908        PromptAccessory::Files(_)
909            | PromptAccessory::Models
910            | PromptAccessory::ReasoningEffort
911            | PromptAccessory::Skills
912    ) {
913        app.picker = None;
914    }
915    app.prompt_accessory = PromptAccessory::None;
916}
917
918/// Open the highest-priority detail surface target.
919///
920/// Priority:
921/// 1. Failed tool output.
922/// 2. Tool output that is likely truncated in the live transcript preview.
923/// 3. Latest available tool output.
924pub fn open_detail_surface(app: &mut App) {
925    let Some(index) = next_detail_target(app) else {
926        return;
927    };
928    app.detail_pane = DetailPane { entry_index: index, scroll: 0, open: true };
929}
930
931pub fn next_detail_target(app: &App) -> Option<usize> {
932    const TOOL_PREVIEW_LINES: usize = 6;
933
934    let mut fallback = None;
935    let mut truncated = None;
936
937    for (index, entry) in app.transcript.iter().enumerate().rev() {
938        let Entry::Tool { status, output, .. } = entry else {
939            continue;
940        };
941
942        fallback.get_or_insert(index);
943
944        if matches!(status, ToolStatus::Failed) {
945            return Some(index);
946        }
947
948        if output.len() > TOOL_PREVIEW_LINES && truncated.is_none() {
949            truncated = Some(index);
950        }
951    }
952
953    truncated.or(fallback)
954}
955
956/// Handle keys while the detail pane is open.
957///
958/// - `Tab`/`Esc` close the pane and return control to the prompt.
959/// - `Up`/`PageUp` scroll up.
960/// - `Down`/`PageDown` scroll down.
961/// - All other keys are swallowed so the prompt is not mutated while the
962///   detail pane has focus.
963pub fn handle_detail_pane_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
964    let total = detail_pane_output_count(app);
965    match key.code {
966        KeyCode::Tab | KeyCode::Esc => {
967            app.detail_pane.open = false;
968            None
969        }
970        KeyCode::Up | KeyCode::PageUp => {
971            app.detail_pane.scroll_up();
972            None
973        }
974        KeyCode::Down | KeyCode::PageDown => {
975            app.detail_pane.scroll_down(total);
976            None
977        }
978        _ => None,
979    }
980}
981
982/// Count output lines available for the detail pane's current target entry.
983pub fn detail_pane_output_count(app: &App) -> usize {
984    let Some(entry) = app.transcript.get(app.detail_pane.entry_index) else {
985        return 0;
986    };
987    match entry {
988        Entry::Tool { output, .. } => output.len(),
989        _ => 0,
990    }
991}
992
993/// Run fuzzy filter and split results into parallel item + index vectors.
994pub fn split_filter_items(all_items: &[PickerItem], query: &str, limit: usize) -> (Vec<PickerItem>, Vec<Vec<usize>>) {
995    if query.trim().is_empty() {
996        return (
997            all_items.iter().take(limit).cloned().collect(),
998            all_items.iter().take(limit).map(|_| Vec::new()).collect(),
999        );
1000    }
1001
1002    let searchable_items: Vec<String> = all_items.iter().map(PickerItem::searchable).collect();
1003    let filtered = fuzzy::fuzzy_filter(&searchable_items, query, limit);
1004    filtered
1005        .into_iter()
1006        .filter_map(|(matched, indices)| {
1007            searchable_items
1008                .iter()
1009                .position(|candidate| candidate == &matched)
1010                .and_then(|idx| all_items.get(idx).cloned().map(|item| (item, indices)))
1011        })
1012        .collect::<Vec<_>>()
1013        .into_iter()
1014        .unzip()
1015}
1016
1017pub fn insert_file_path(app: &mut App, path: &str) {
1018    if !app.input.is_empty() && !app.input.text_before_cursor().ends_with(char::is_whitespace) {
1019        app.input.insert_char(' ');
1020    }
1021    app.input.insert_str(path);
1022}
1023
1024pub fn accept_file_suggestion(app: &mut App) {
1025    let Some(path) = app
1026        .picker
1027        .as_ref()
1028        .and_then(|picker| picker.selected().map(|item| item.label.clone()))
1029    else {
1030        return;
1031    };
1032
1033    match app.prompt_accessory {
1034        PromptAccessory::Files(FilePickerSource::Mention { token_start }) => {
1035            let end = app.input.cursor();
1036            let replacement = format!("@{path} ");
1037            app.input.replace_range(token_start, end, &replacement);
1038        }
1039        PromptAccessory::Files(FilePickerSource::Forced) => {
1040            insert_file_path(app, &path);
1041        }
1042        _ => {}
1043    }
1044
1045    close_prompt_accessory(app);
1046}
1047
1048pub fn accept_model_suggestion(app: &mut App) {
1049    let Some(model) = app
1050        .picker
1051        .as_ref()
1052        .and_then(|picker| picker.selected().map(|item| item.label.clone()))
1053    else {
1054        return;
1055    };
1056
1057    app.model = model.clone();
1058    app.cli.model = model.clone();
1059    match config::write_project_model(&app.cwd, &model) {
1060        Ok(path) => {
1061            let display = config::project_config_path_display(&path, &app.cwd);
1062            app.transcript
1063                .push(Entry::Status { text: format!("model: {model} (saved to {display})") });
1064        }
1065        Err(err) => {
1066            app.transcript.push(Entry::Status { text: format!("model: {model}") });
1067            app.transcript
1068                .push(Entry::Error { text: format!("failed to save selected model to project config: {err}") });
1069        }
1070    }
1071    if crate::providers::reasoning_options(&model).len() > 1 {
1072        open_reasoning_effort_picker(app);
1073    } else {
1074        close_prompt_accessory(app);
1075    }
1076}
1077
1078pub fn accept_reasoning_effort_suggestion(app: &mut App) {
1079    let Some(effort) = app
1080        .picker
1081        .as_ref()
1082        .and_then(|picker| picker.selected())
1083        .and_then(|item| ReasoningEffort::parse(&item.label))
1084    else {
1085        return;
1086    };
1087
1088    if !crate::providers::reasoning_option_is_supported(&app.model, effort) {
1089        app.transcript.push(Entry::Error {
1090            text: format!(
1091                "reasoning control `{}` is not supported by {}",
1092                effort.label(),
1093                app.model
1094            ),
1095        });
1096        return;
1097    }
1098
1099    app.cli.reasoning_effort = effort;
1100    let pending_setup = app.pending_setup_reasoning_effort;
1101    match write_reasoning_effort_config(app, effort, pending_setup.map(|pending| pending.scope)) {
1102        Ok((_path, display)) => {
1103            app.transcript
1104                .push(Entry::Status { text: format!("reasoning effort: {} (saved to {display})", effort.label()) });
1105        }
1106        Err(err) => {
1107            app.transcript
1108                .push(Entry::Status { text: format!("reasoning effort: {}", effort.label()) });
1109            app.transcript
1110                .push(Entry::Error { text: format!("failed to save reasoning effort to project config: {err}") });
1111        }
1112    }
1113    finish_reasoning_effort_picker(app);
1114}
1115
1116pub fn write_reasoning_effort_config(
1117    app: &App, effort: ReasoningEffort, scope: Option<CredentialScope>,
1118) -> std::io::Result<(PathBuf, String)> {
1119    let (path, display) = match scope {
1120        Some(CredentialScope::Global) => {
1121            let path = config::global_config_path()
1122                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is not available"))?;
1123            let display = config::global_config_path_display(&path);
1124            (path, display)
1125        }
1126        Some(CredentialScope::Project) | None => {
1127            let path = config::project_config_path(&app.cwd);
1128            let display = config::project_config_path_display(&path, &app.cwd);
1129            (path, display)
1130        }
1131    };
1132    config::write_reasoning_effort_config(&path, effort)?;
1133    Ok((path, display))
1134}
1135
1136pub fn finish_reasoning_effort_picker(app: &mut App) {
1137    close_prompt_accessory(app);
1138    if let Some(pending) = app.pending_setup_reasoning_effort.take() {
1139        advance_after_setup_model_config(app, pending.provider);
1140    }
1141}
1142
1143pub fn accept_skill_suggestion(app: &mut App) {
1144    let Some(name) = app
1145        .picker
1146        .as_ref()
1147        .and_then(|picker| picker.selected().map(|item| item.label.clone()))
1148    else {
1149        return;
1150    };
1151    let Some(skill) = app.skills.iter().find(|skill| skill.name == name).cloned() else {
1152        close_prompt_accessory(app);
1153        return;
1154    };
1155
1156    match skills::load_skill(&skill) {
1157        Ok(loaded) => {
1158            for diagnostic in &loaded.diagnostics {
1159                app.transcript.push(Entry::Error { text: diagnostic.summary() });
1160            }
1161            let text = format!(
1162                "# Skill: {}\n\n_Source: {}_\n\n{}",
1163                loaded.activation.name,
1164                loaded.activation.path.display(),
1165                loaded.markdown
1166            );
1167            app.transcript.push(Entry::Agent { text, streaming: false });
1168            if let Some(ref mut writer) = app.session_writer {
1169                let _ = writer.append_skill_activation(&loaded.activation);
1170            }
1171        }
1172        Err(diagnostic) => app.transcript.push(Entry::Error { text: diagnostic.summary() }),
1173    }
1174    close_prompt_accessory(app);
1175}
1176
1177pub fn sync_prompt_accessory(app: &mut App) {
1178    if app.mode == Mode::Command {
1179        app.prompt_accessory = PromptAccessory::Commands { selected: 0 };
1180        return;
1181    }
1182
1183    if app.input.as_str().starts_with('/') {
1184        app.prompt_accessory = PromptAccessory::Commands { selected: 0 };
1185        return;
1186    }
1187
1188    if let Some((token_start, _query)) = active_at_token(app) {
1189        if !matches!(app.prompt_accessory, PromptAccessory::Files(FilePickerSource::Mention { token_start: existing }) if existing == token_start)
1190        {
1191            open_file_picker(app, FilePickerSource::Mention { token_start });
1192        } else {
1193            sync_file_picker_query(app);
1194        }
1195        return;
1196    }
1197
1198    if !matches!(app.prompt_accessory, PromptAccessory::Help) {
1199        close_prompt_accessory(app);
1200    }
1201}
1202
1203pub fn active_at_token(app: &App) -> Option<(usize, String)> {
1204    let before = app.input.text_before_cursor();
1205    let chars: Vec<char> = before.chars().collect();
1206    let token_start = chars.iter().rposition(|ch| ch.is_whitespace()).map_or(0, |idx| idx + 1);
1207    if chars.get(token_start) != Some(&'@') {
1208        return None;
1209    }
1210    let query: String = chars[token_start + 1..].iter().collect();
1211    Some((token_start, query))
1212}
1213
1214pub fn sync_file_picker_query(app: &mut App) {
1215    let query = match app.prompt_accessory {
1216        PromptAccessory::Files(FilePickerSource::Mention { .. }) => active_at_token(app).map(|(_, query)| query),
1217        PromptAccessory::Files(FilePickerSource::Forced) => app.picker.as_ref().map(|picker| picker.query.clone()),
1218        _ => None,
1219    };
1220    let Some(query) = query else {
1221        return;
1222    };
1223    if let Some(picker) = app.picker.as_mut()
1224        && picker.query != query
1225    {
1226        picker.query = query;
1227        picker.refresh_matches();
1228    }
1229}
1230
1231pub fn handle_submit(app: &mut App) -> Option<Msg> {
1232    if app.pending_permission.is_some() {
1233        return None;
1234    }
1235
1236    if app.run_state == RunState::Working {
1237        let text = app.input.as_str().trim().to_string();
1238        if text.is_empty() {
1239            app.input.clear();
1240            return None;
1241        }
1242        if let Some(literal) = text.strip_prefix("//") {
1243            queue_running_input(app, &format!("/{literal}"));
1244            return None;
1245        }
1246        if let Some(command) = text.strip_prefix('/') {
1247            app.input.clear();
1248            return handle_running_command(app, command);
1249        }
1250        queue_running_input(app, &text);
1251        return None;
1252    }
1253
1254    if !matches!(app.run_state, RunState::Idle | RunState::Error(_)) {
1255        return None;
1256    }
1257
1258    let text = app.input.as_str().trim().to_string();
1259    if text.is_empty() {
1260        app.input.clear();
1261        return None;
1262    }
1263
1264    if let Some(command) = text.strip_prefix('/') {
1265        return handle_command(app, command);
1266    }
1267
1268    submit_user_turn(app, text)
1269}
1270
1271pub fn queue_running_input(app: &mut App, text: &str) {
1272    app.input.clear();
1273    agent_lifecycle::remember_input(app, text);
1274    let (kind, count) = match app.queue_target {
1275        QueueTarget::Steering => {
1276            app.queued_steering.push(text.to_string());
1277            ("steering", app.queued_steering.len())
1278        }
1279        QueueTarget::FollowUp => {
1280            app.queued_followups.push(text.to_string());
1281            ("follow-up", app.queued_followups.len())
1282        }
1283    };
1284    let audit_error = app
1285        .session_writer
1286        .as_mut()
1287        .and_then(|writer| writer.append_queued(kind, text).err());
1288    app.transcript
1289        .push(Entry::Status { text: format!("queued {kind} ({count})") });
1290    if let Some(err) = audit_error {
1291        app.transcript
1292            .push(Entry::Error { text: format!("failed to record queued {kind} in session audit log: {err}") });
1293    }
1294}
1295
1296pub fn submit_user_turn(app: &mut App, text: String) -> Option<Msg> {
1297    start_turn(app, text, true)
1298}
1299
1300/// Start an internal provider turn without adding it to the user transcript,
1301/// input history, or durable user-turn records.
1302///
1303/// Compaction uses this for its configured-model summary request. It remains
1304/// an ordinary agent turn for lifecycle and cancellation purposes, but is not
1305/// text the user entered and should never be rendered as such.
1306pub fn submit_internal_turn(app: &mut App, text: String) -> Option<Msg> {
1307    start_turn(app, text, false)
1308}
1309
1310fn start_turn(app: &mut App, text: String, record_user_entry: bool) -> Option<Msg> {
1311    if app.pending_compaction_review.is_some() {
1312        app.transcript
1313            .push(Entry::Error { text: "review the pending compaction before submitting another turn".to_string() });
1314        app.input.set_text(&text);
1315        return None;
1316    }
1317    if let Some(recovery) = selected_provider_missing(app) {
1318        app.first_run_recovery = Some(recovery);
1319        return None;
1320    }
1321
1322    let user_entry = record_user_entry.then(|| Entry::User { text: text.clone() });
1323    if let Some(entry) = user_entry.as_ref() {
1324        agent_lifecycle::remember_input(app, &text);
1325        app.transcript.push(entry.clone());
1326    }
1327    app.input.clear();
1328    app.history_cursor = None;
1329    app.history_draft.clear();
1330    app.last_input = Some(text);
1331    app.ttft.start_turn();
1332    app.turn_count += 1;
1333    let turn_id = format!("turn_{}", app.turn_count);
1334    agent_lifecycle::refresh_mcp_config_audit(app, &turn_id);
1335    if let Some(ref mut writer) = app.session_writer
1336        && let Some(entry) = user_entry.as_ref()
1337    {
1338        let _ = writer.append_entry(entry, &turn_id);
1339    }
1340    Some(Msg::Agent(AgentEvent::Started))
1341}