1use super::*;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
11pub enum Mode {
12 #[default]
14 Prompt,
15 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 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 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
141pub 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
479pub 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
519pub 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
788pub 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
918pub 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
956pub 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
982pub 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
993pub 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
1300pub 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}