1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5
6use crate::tui::events::UserAction;
7
8use super::{
9 double_press_window, glob_workspace_files, search_history, App, InputMode, TranscriptBlock,
10};
11
12impl App {
13 pub fn handle_key(&mut self, key: KeyEvent) -> Option<UserAction> {
16 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
21 return self.handle_ctrl_c();
22 }
23
24 if self.pending_permission.is_some() {
28 return self.handle_permission_key(key);
29 }
30
31 if self.plan_mode_request_pending {
35 return self.handle_plan_mode_request_key(key);
36 }
37
38 if !self.modals.is_empty() {
44 return self.handle_modal_key_action(key);
45 }
46
47 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
54 if self.prompt.buffer.is_empty() {
55 self.toggle_last_expandable();
56 } else {
57 self.prompt.move_end();
58 }
59 return None;
60 }
61
62 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('a') {
64 self.prompt.move_home();
65 return None;
66 }
67
68 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
80 self.prompt.move_left();
81 return None;
82 }
83 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('f') {
84 self.prompt.move_right();
85 return None;
86 }
87 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('p') {
88 self.prompt.move_prev_line();
89 return None;
90 }
91 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('n') {
92 self.prompt.move_next_line();
93 return None;
94 }
95
96 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
101 match self.prompt.mode {
102 InputMode::Prompt => {
103 self.enter_history_search_mode();
104 return None;
105 }
106 InputMode::HistorySearch => {
107 if !self.hsearch_matches.is_empty() {
109 self.hsearch_selected =
110 (self.hsearch_selected + 1) % self.hsearch_matches.len();
111 }
112 return None;
113 }
114 _ => return None,
115 }
116 }
117
118 if key.code == KeyCode::BackTab {
120 self.prompt.mode = self.prompt.mode.cycle_next();
121 return None;
122 }
123
124 if self.prompt.mode == InputMode::HistorySearch {
126 return self.handle_history_search_key(key);
127 }
128
129 if self.prompt.mode == InputMode::AtFile {
133 return self.handle_atfile_key(key);
134 }
135
136 if self.prompt.mode == InputMode::Command {
141 if let Some(action) = self.handle_command_menu_key(key) {
142 return action;
143 }
144 }
145
146 match key.code {
148 KeyCode::Enter
149 if key.modifiers.contains(KeyModifiers::SHIFT)
150 || key.modifiers.contains(KeyModifiers::ALT)
151 || key.modifiers.contains(KeyModifiers::CONTROL) =>
152 {
153 self.prompt.insert_char('\n');
159 None
160 }
161 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
166 self.prompt.insert_char('\n');
167 None
168 }
169 KeyCode::Enter => self.submit_prompt(),
170 KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
178 self.scroll_offset = self.scroll_offset.saturating_add(1);
179 None
180 }
181 KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
182 self.scroll_offset = self.scroll_offset.saturating_sub(1);
183 None
184 }
185 KeyCode::PageUp => {
186 self.scroll_offset = self.scroll_offset.saturating_add(10);
187 None
188 }
189 KeyCode::PageDown => {
190 self.scroll_offset = self.scroll_offset.saturating_sub(10);
191 None
192 }
193 KeyCode::Up if self.should_walk_history_up() => {
194 self.prompt.history_prev();
195 None
196 }
197 KeyCode::Down if self.should_walk_history_down() => {
198 self.prompt.history_next();
199 None
200 }
201 KeyCode::Char('q') if self.prompt.buffer.is_empty() => {
202 self.should_quit = true;
203 None
204 }
205 KeyCode::Char(c) => {
206 self.handle_char_input(c);
207 None
208 }
209 KeyCode::Backspace => {
210 if self.prompt.buffer.is_empty() && self.prompt.mode != InputMode::Prompt {
211 self.prompt.mode = InputMode::Prompt;
215 } else {
216 self.prompt.backspace();
217 }
218 None
219 }
220 KeyCode::Delete => {
221 self.prompt.delete_forward();
222 None
223 }
224 KeyCode::Left => {
225 self.prompt.move_left();
226 None
227 }
228 KeyCode::Right => {
229 self.prompt.move_right();
230 None
231 }
232 KeyCode::Home => {
233 self.prompt.move_home();
234 None
235 }
236 KeyCode::End => {
237 self.prompt.move_end();
238 None
239 }
240 KeyCode::Esc => self.handle_esc(),
241 _ => None,
242 }
243 }
244
245 fn handle_esc(&mut self) -> Option<UserAction> {
259 use std::time::Instant;
260
261 let now = Instant::now();
262 let _within_window = self
263 .double_press
264 .last_esc_at
265 .map(|t| now.duration_since(t) <= double_press_window())
266 .unwrap_or(false);
267 self.double_press.last_esc_at = Some(now);
268
269 if !self.prompt.buffer.is_empty() || self.prompt.mode != InputMode::Prompt {
271 self.prompt.buffer.clear();
272 self.prompt.cursor = 0;
273 self.prompt.mode = InputMode::Prompt;
274 self.prompt.history_idx = None;
275 return None;
276 }
277
278 if self.turn.running {
280 self.push_system("Interrupting… (press Ctrl+C again to exit)");
281 return Some(UserAction::Interrupt);
282 }
283
284 None
286 }
287
288 fn handle_ctrl_c(&mut self) -> Option<UserAction> {
297 use std::time::Instant;
298
299 let now = Instant::now();
300 let within_window = self
301 .double_press
302 .last_ctrl_c_at
303 .map(|t| now.duration_since(t) <= double_press_window())
304 .unwrap_or(false);
305
306 if within_window {
307 self.should_quit = true;
309 self.double_press.last_ctrl_c_at = None;
310 return None;
311 }
312
313 self.double_press.last_ctrl_c_at = Some(now);
314
315 if !self.modals.is_empty() {
317 self.modals.pop();
318 return None;
319 }
320
321 if !self.prompt.buffer.is_empty() || self.prompt.mode != InputMode::Prompt {
323 self.prompt.buffer.clear();
324 self.prompt.cursor = 0;
325 self.prompt.mode = InputMode::Prompt;
326 self.prompt.history_idx = None;
327 return None;
328 }
329
330 if self.turn.running {
332 self.push_system("Interrupting… (press Ctrl+C again to exit)");
333 return Some(UserAction::Interrupt);
334 }
335
336 self.push_system("Press Ctrl+C again to exit");
338 None
339 }
340
341 fn should_walk_history_up(&self) -> bool {
346 if self.prompt.history.is_empty() {
347 return false;
348 }
349 self.prompt.history_idx.is_some() || self.prompt.buffer.is_empty()
350 }
351
352 fn should_walk_history_down(&self) -> bool {
353 self.prompt.history_idx.is_some()
354 }
355
356 fn handle_char_input(&mut self, c: char) {
357 if self.prompt.buffer.is_empty() && self.prompt.mode == InputMode::Prompt {
361 match c {
362 '!' => {
363 self.prompt.mode = InputMode::Bash;
364 return;
365 }
366 '#' => {
367 self.prompt.mode = InputMode::Note;
368 return;
369 }
370 '/' => {
371 self.prompt.mode = InputMode::Command;
372 return;
373 }
374 _ => {}
375 }
376 }
377 if c == '@' && self.prompt.mode == InputMode::Prompt {
381 self.prompt.insert_char('@');
382 self.enter_atfile_mode();
383 return;
384 }
385 self.prompt.insert_char(c);
386 }
387
388 fn submit_prompt(&mut self) -> Option<UserAction> {
392 if self.prompt.buffer.is_empty() {
393 return None;
397 }
398 let mode = self.prompt.mode;
399 let body = self.prompt.buffer.clone();
400 let prefixed = format!("{}{}", mode.history_prefix(), body);
401
402 let action = match mode {
403 InputMode::Prompt => {
404 self.blocks
405 .push(TranscriptBlock::User { text: body.clone() });
406 self.scroll_to_bottom();
407 self.start_turn();
408 Some(UserAction::SendMessage(body))
409 }
410 InputMode::Bash => {
411 self.blocks.push(TranscriptBlock::User {
412 text: format!("!{body}"),
413 });
414 self.scroll_to_bottom();
415 Some(UserAction::RunShell(body))
416 }
417 InputMode::Note => {
418 self.blocks.push(TranscriptBlock::System {
419 text: format!("# {body}"),
420 });
421 self.scroll_to_bottom();
422 None
423 }
424 InputMode::Command => self.dispatch_slash_command(&body),
425 InputMode::AtFile => {
429 self.exit_atfile_mode();
430 self.blocks
431 .push(TranscriptBlock::User { text: body.clone() });
432 self.scroll_to_bottom();
433 self.start_turn();
434 Some(UserAction::SendMessage(body))
435 }
436 InputMode::HistorySearch => {
439 self.exit_history_search_mode();
440 self.blocks
441 .push(TranscriptBlock::User { text: body.clone() });
442 self.scroll_to_bottom();
443 self.start_turn();
444 Some(UserAction::SendMessage(body))
445 }
446 };
447
448 self.prompt.record_submission(prefixed);
449 self.command_menu_selected = None;
450 action
451 }
452
453 fn dispatch_slash_command(&mut self, body: &str) -> Option<UserAction> {
457 use crate::tui::commands::{CommandHandler, CommandOutcome};
458
459 let mut parts = body.split_whitespace();
460 let name = parts.next().unwrap_or("");
461 let args: Vec<String> = parts.map(String::from).collect();
462
463 let registry = self.commands.clone();
466
467 if let Some(spec) = registry.lookup(name) {
469 return match &spec.handler {
470 CommandHandler::Sync(f) => {
471 match f(self, &args) {
472 CommandOutcome::Done => {}
473 CommandOutcome::Error(msg) => self.push_error(msg),
474 CommandOutcome::OpenModal(modal) => self.push_modal(modal),
475 }
476 None
477 }
478 CommandHandler::Async(f) => {
479 let actions = f(self, &args);
480 actions.into_iter().next()
485 }
486 };
487 }
488
489 if let Some(skill) = registry.lookup_skill(name) {
491 let args_str = args.join(" ");
492 let prompt = skill.expand(&args_str);
493 self.push_system(format!(
494 "Running skill /{}: {}",
495 skill.name, skill.description
496 ));
497 self.blocks.push(TranscriptBlock::User {
498 text: prompt.clone(),
499 });
500 self.scroll_to_bottom();
501 self.start_turn();
502 return Some(UserAction::RunSkillPrompt { prompt });
503 }
504
505 self.push_error(format!("Unknown command: /{name}. Try /help."));
506 None
507 }
508
509 pub fn handle_command_menu_key(&mut self, key: KeyEvent) -> Option<Option<UserAction>> {
516 use crate::tui::ui::command_menu;
517 let matches_count = self.commands.search(&self.prompt.buffer).len();
518
519 match key.code {
520 KeyCode::Up => {
521 match self.command_menu_selected {
522 None => return None,
523 Some(0) => self.command_menu_selected = None,
524 Some(n) => self.command_menu_selected = Some(n - 1),
525 }
526 Some(None)
527 }
528 KeyCode::Down => {
529 if matches_count == 0 {
530 return None;
531 }
532 let next = match self.command_menu_selected {
533 None => 0,
534 Some(n) if n + 1 < matches_count.min(command_menu::MAX_VISIBLE) => n + 1,
535 Some(n) => n,
536 };
537 self.command_menu_selected = Some(next);
538 Some(None)
539 }
540 KeyCode::Tab => {
541 let registry = self.commands.clone();
542 let matches = registry.search(&self.prompt.buffer);
543 if let Some(target) =
544 command_menu::tab_completion_target(&self.prompt.buffer, &matches)
545 {
546 self.prompt.buffer = target;
547 self.prompt.cursor = self.prompt.buffer.len();
548 self.command_menu_selected = None;
549 }
550 Some(None)
551 }
552 KeyCode::Enter => {
553 if let Some(idx) = self.command_menu_selected {
557 let registry = self.commands.clone();
558 let matches = registry.search(&self.prompt.buffer);
559 if let Some(spec) = matches.get(idx) {
560 let chosen = spec.name.to_string();
561 self.prompt.buffer = chosen;
562 self.prompt.cursor = self.prompt.buffer.len();
563 }
564 self.command_menu_selected = None;
565 }
566 None
567 }
568 _ => None,
569 }
570 }
571
572 fn enter_atfile_mode(&mut self) {
576 self.prompt.mode = InputMode::AtFile;
577 self.atfile_query.clear();
578 self.atfile_selected = None;
579 self.atfile_suggestions = glob_workspace_files("");
580 }
581
582 fn refresh_atfile_suggestions(&mut self) {
584 self.atfile_suggestions = glob_workspace_files(&self.atfile_query);
585 if let Some(sel) = self.atfile_selected {
587 if sel >= self.atfile_suggestions.len() {
588 self.atfile_selected = if self.atfile_suggestions.is_empty() {
589 None
590 } else {
591 Some(self.atfile_suggestions.len() - 1)
592 };
593 }
594 }
595 }
596
597 fn commit_atfile_selection(&mut self) {
600 let idx = self.atfile_selected.unwrap_or(0);
601 let Some(chosen) = self.atfile_suggestions.get(idx).cloned() else {
602 self.exit_atfile_mode();
603 return;
604 };
605 let at_pos = self
607 .prompt
608 .buffer
609 .rfind('@')
610 .unwrap_or(self.prompt.buffer.len());
611 self.prompt.buffer.truncate(at_pos);
612 self.prompt.buffer.push('@');
613 self.prompt.buffer.push_str(&chosen);
614 self.prompt.cursor = self.prompt.buffer.len();
615 self.exit_atfile_mode();
616 }
617
618 fn exit_atfile_mode(&mut self) {
620 self.prompt.mode = InputMode::Prompt;
621 self.atfile_query.clear();
622 self.atfile_suggestions.clear();
623 self.atfile_selected = None;
624 }
625
626 pub fn handle_atfile_key(&mut self, key: KeyEvent) -> Option<UserAction> {
628 match key.code {
629 KeyCode::Esc => {
630 self.exit_atfile_mode();
632 None
633 }
634 KeyCode::Enter | KeyCode::Tab => {
635 self.commit_atfile_selection();
636 None
637 }
638 KeyCode::Up => {
639 match self.atfile_selected {
640 None => {}
641 Some(0) => self.atfile_selected = None,
642 Some(n) => self.atfile_selected = Some(n - 1),
643 }
644 None
645 }
646 KeyCode::Down => {
647 let count = self.atfile_suggestions.len();
648 if count == 0 {
649 return None;
650 }
651 let next = match self.atfile_selected {
652 None => 0,
653 Some(n) if n + 1 < count => n + 1,
654 Some(n) => n,
655 };
656 self.atfile_selected = Some(next);
657 None
658 }
659 KeyCode::Backspace => {
660 if self.atfile_query.is_empty() {
661 self.exit_atfile_mode();
663 self.prompt.backspace(); } else {
665 let last_len = self
667 .atfile_query
668 .chars()
669 .last()
670 .map(|c| c.len_utf8())
671 .unwrap_or(0);
672 let new_len = self.atfile_query.len() - last_len;
673 self.atfile_query.truncate(new_len);
674 self.prompt.backspace();
675 self.refresh_atfile_suggestions();
676 }
677 None
678 }
679 KeyCode::Char(c) => {
680 self.atfile_query.push(c);
681 self.prompt.insert_char(c);
682 self.refresh_atfile_suggestions();
683 None
684 }
685 _ => None,
686 }
687 }
688
689 fn enter_history_search_mode(&mut self) {
694 self.prompt.mode = InputMode::HistorySearch;
695 self.hsearch_query.clear();
696 self.hsearch_selected = 0;
697 self.hsearch_matches = search_history(&self.prompt.history, "");
698 }
699
700 fn refresh_hsearch_matches(&mut self) {
702 self.hsearch_matches = search_history(&self.prompt.history, &self.hsearch_query);
703 if self.hsearch_selected >= self.hsearch_matches.len().max(1) {
704 self.hsearch_selected = 0;
705 }
706 }
707
708 fn commit_history_selection(&mut self) {
711 if let Some(&hist_idx) = self.hsearch_matches.get(self.hsearch_selected) {
712 if let Some(entry) = self.prompt.history.get(hist_idx) {
713 self.prompt.buffer = entry.clone();
714 self.prompt.cursor = self.prompt.buffer.len();
715 }
716 }
717 self.exit_history_search_mode();
718 }
719
720 fn exit_history_search_mode(&mut self) {
722 self.prompt.mode = InputMode::Prompt;
723 self.hsearch_query.clear();
724 self.hsearch_matches.clear();
725 self.hsearch_selected = 0;
726 }
727
728 pub fn handle_history_search_key(&mut self, key: KeyEvent) -> Option<UserAction> {
730 match key.code {
731 KeyCode::Esc => {
732 self.exit_history_search_mode();
733 None
734 }
735 KeyCode::Enter => {
736 self.commit_history_selection();
737 None
738 }
739 KeyCode::Up => {
740 if !self.hsearch_matches.is_empty() && self.hsearch_selected > 0 {
741 self.hsearch_selected -= 1;
742 }
743 None
744 }
745 KeyCode::Down => {
746 if !self.hsearch_matches.is_empty()
747 && self.hsearch_selected + 1 < self.hsearch_matches.len()
748 {
749 self.hsearch_selected += 1;
750 }
751 None
752 }
753 KeyCode::Backspace => {
754 if self.hsearch_query.is_empty() {
755 self.exit_history_search_mode();
756 } else {
757 let last_len = self
758 .hsearch_query
759 .chars()
760 .last()
761 .map(|c| c.len_utf8())
762 .unwrap_or(0);
763 let new_len = self.hsearch_query.len() - last_len;
764 self.hsearch_query.truncate(new_len);
765 self.refresh_hsearch_matches();
766 }
767 None
768 }
769 KeyCode::Char(c) => {
770 self.hsearch_query.push(c);
771 self.refresh_hsearch_matches();
772 None
773 }
774 _ => None,
775 }
776 }
777
778 pub fn handle_permission_key(&mut self, key: KeyEvent) -> Option<UserAction> {
785 let (allow, auto_allow) = match key.code {
786 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => (true, false),
787 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => (false, false),
788 KeyCode::Char('a') | KeyCode::Char('A') => (true, true),
789 _ => return None,
790 };
791 if let Some(p) = self.pending_permission.take() {
792 if auto_allow {
793 self.auto_allowed_tools.insert(p.tool_name.clone());
794 }
795 let _ = p.reply.send(allow);
796 }
797 None
798 }
799
800 pub fn handle_modal_key_action(&mut self, key: KeyEvent) -> Option<UserAction> {
808 use crate::tui::ui::modal::Modal;
809
810 if let Some(Modal::PlanReview { .. }) = self.modals.last() {
813 return self.handle_plan_review_key(key);
814 }
815
816 if let Some(Modal::ResumePicker { .. }) = self.modals.last() {
818 return self.handle_resume_picker_key(key);
819 }
820
821 if let Some(Modal::McpServers { .. }) = self.modals.last() {
823 return self.handle_mcp_servers_key(key);
824 }
825
826 self.handle_modal_key(key);
828 None
829 }
830
831 fn handle_plan_review_key(&mut self, key: KeyEvent) -> Option<UserAction> {
846 use crate::tui::ui::modal::Modal;
847
848 match key.code {
849 KeyCode::Char('y') | KeyCode::Enter => {
850 self.modals.pop();
854 Some(UserAction::ConfirmPlan)
855 }
856 KeyCode::Char('n') | KeyCode::Esc => {
857 self.modals.pop();
858 Some(UserAction::RejectPlan("user rejected".into()))
859 }
860 KeyCode::Char('e') => {
861 if let Some(Modal::PlanReview { plan_text, .. }) = self.modals.last().cloned() {
862 self.set_input(plan_text);
863 }
864 self.modals.pop();
865 None
866 }
867 _ => None,
868 }
869 }
870
871 fn handle_plan_mode_request_key(&mut self, key: KeyEvent) -> Option<UserAction> {
879 match key.code {
880 KeyCode::Char('y') | KeyCode::Enter => {
881 self.plan_mode_request_pending = false;
882 Some(UserAction::ApprovePlanMode)
883 }
884 KeyCode::Char('n') | KeyCode::Esc => {
885 self.plan_mode_request_pending = false;
886 Some(UserAction::RejectPlanMode("user skipped".into()))
887 }
888 _ => None,
889 }
890 }
891
892 fn handle_resume_picker_key(&mut self, key: KeyEvent) -> Option<UserAction> {
894 use crate::tui::ui::modal::Modal;
895 match key.code {
896 KeyCode::Esc | KeyCode::Char('q') => {
897 self.modals.pop();
898 None
899 }
900 KeyCode::Up => {
901 let mut new_sel: Option<usize> = None;
902 if let Some(Modal::ResumePicker { selected, .. }) = self.modals.last_mut() {
903 if *selected > 0 {
904 *selected -= 1;
905 }
906 new_sel = Some(*selected);
907 }
908 if let Some(sel) = new_sel {
909 self.modal_scroll_follow_selection(sel);
910 }
911 None
912 }
913 KeyCode::Down => {
914 let mut new_sel: Option<usize> = None;
915 if let Some(Modal::ResumePicker { entries, selected }) = self.modals.last_mut() {
916 if *selected + 1 < entries.len() {
917 *selected += 1;
918 }
919 new_sel = Some(*selected);
920 }
921 if let Some(sel) = new_sel {
922 self.modal_scroll_follow_selection(sel);
923 }
924 None
925 }
926 KeyCode::Enter => {
927 if let Some(Modal::ResumePicker { entries, selected }) = self.modals.last() {
928 if let Some(entry) = entries.get(*selected) {
929 let session_dir = entry.session_dir.clone();
930 self.modals.pop();
931 return Some(UserAction::ResumeSession { session_dir });
932 }
933 }
934 self.modals.pop();
935 None
936 }
937 _ => None,
938 }
939 }
940
941 fn handle_mcp_servers_key(&mut self, key: KeyEvent) -> Option<UserAction> {
943 use crate::tui::ui::modal::Modal;
944 match key.code {
945 KeyCode::Esc | KeyCode::Char('q') => {
946 self.modals.pop();
947 None
948 }
949 KeyCode::Up => {
950 let mut new_sel: Option<usize> = None;
951 if let Some(Modal::McpServers { selected, .. }) = self.modals.last_mut() {
952 if *selected > 0 {
953 *selected -= 1;
954 }
955 new_sel = Some(*selected);
956 }
957 if let Some(sel) = new_sel {
958 self.modal_scroll_follow_selection(sel);
959 }
960 None
961 }
962 KeyCode::Down => {
963 let mut new_sel: Option<usize> = None;
964 if let Some(Modal::McpServers { entries, selected }) = self.modals.last_mut() {
965 if *selected + 1 < entries.len() {
966 *selected += 1;
967 }
968 new_sel = Some(*selected);
969 }
970 if let Some(sel) = new_sel {
971 self.modal_scroll_follow_selection(sel);
972 }
973 None
974 }
975 _ => None,
976 }
977 }
978
979 pub fn handle_modal_key(&mut self, key: KeyEvent) -> bool {
983 use crate::tui::ui::modal::{ConfirmAction, Modal};
984 if self.modals.is_empty() {
985 return false;
986 }
987 match key.code {
988 KeyCode::Esc | KeyCode::Char('q') => {
989 self.modals.pop();
990 }
991 KeyCode::Char('y') => {
992 if let Some(Modal::Confirm { on_yes, .. }) = self.modals.last().cloned() {
993 self.modals.pop();
994 match on_yes {
995 ConfirmAction::Exit => {
996 self.should_quit = true;
997 }
998 ConfirmAction::Clear => {
999 self.reset_transcript();
1000 }
1001 }
1002 }
1003 }
1004 KeyCode::Char('n') => {
1005 if matches!(self.modals.last(), Some(Modal::Confirm { .. })) {
1006 self.modals.pop();
1007 }
1008 }
1009 KeyCode::Enter => {
1010 if let Some(Modal::Confirm { on_yes, .. }) = self.modals.last().cloned() {
1011 self.modals.pop();
1012 match on_yes {
1013 ConfirmAction::Exit => self.should_quit = true,
1014 ConfirmAction::Clear => self.reset_transcript(),
1015 }
1016 } else {
1017 self.modals.pop();
1019 }
1020 }
1021 KeyCode::Up | KeyCode::PageUp => {
1022 let step: u16 = if key.code == KeyCode::PageUp { 10 } else { 1 };
1023 let mut journal_new_sel: Option<usize> = None;
1025 if let Some(Modal::Journal { selected, .. }) = self.modals.last_mut() {
1026 if *selected > 0 {
1027 *selected -= 1;
1028 }
1029 journal_new_sel = Some(*selected);
1030 }
1031 if let Some(sel) = journal_new_sel {
1032 self.modal_scroll_follow_selection(sel);
1033 } else {
1034 self.modal_scroll = self.modal_scroll.saturating_sub(step);
1036 }
1037 }
1038 KeyCode::Down | KeyCode::PageDown => {
1039 let step: u16 = if key.code == KeyCode::PageDown { 10 } else { 1 };
1040 let mut journal_new_sel: Option<usize> = None;
1042 if let Some(Modal::Journal { entries, selected }) = self.modals.last_mut() {
1043 if *selected + 1 < entries.len() {
1044 *selected += 1;
1045 }
1046 journal_new_sel = Some(*selected);
1047 }
1048 if let Some(sel) = journal_new_sel {
1049 self.modal_scroll_follow_selection(sel);
1050 } else {
1051 self.modal_scroll = self.modal_scroll.saturating_add(step);
1053 }
1054 }
1055 _ => {}
1056 }
1057 true
1058 }
1059
1060 const MODAL_LIST_VISIBLE: u16 = 28;
1063
1064 fn modal_scroll_follow_selection(&mut self, selected: usize) {
1068 let row = selected as u16 + 2; if row < self.modal_scroll {
1070 self.modal_scroll = row.saturating_sub(1);
1071 } else if row + 1 > self.modal_scroll + Self::MODAL_LIST_VISIBLE {
1072 self.modal_scroll = row + 1 - Self::MODAL_LIST_VISIBLE;
1073 }
1074 }
1075}
1076
1077#[cfg(test)]
1082mod tests {
1083 use std::time::Duration;
1084
1085 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1086
1087 use crate::tui::app::{App, AppScreen, InputMode, ToolResultData, TranscriptBlock};
1088 use crate::tui::events::{UiEvent, UserAction};
1089
1090 fn key(code: KeyCode) -> KeyEvent {
1091 KeyEvent::new(code, KeyModifiers::NONE)
1092 }
1093
1094 fn ctrl(c: char) -> KeyEvent {
1095 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1096 }
1097
1098 fn shift(code: KeyCode) -> KeyEvent {
1099 KeyEvent::new(code, KeyModifiers::SHIFT)
1100 }
1101
1102 #[test]
1105 fn ctrl_e_toggles_expanded_on_last_tool_result() {
1106 let mut app = App::new();
1107 app.screen = AppScreen::Chat;
1108 app.handle_ui_event(UiEvent::ToolResult {
1112 id: "1".into(),
1113 name: "read_file".into(),
1114 output: "long output".into(),
1115 success: true,
1116 });
1117 let _ = app.handle_key(ctrl('e'));
1118 match app.blocks.last() {
1119 Some(TranscriptBlock::ToolCall {
1120 result: Some(ToolResultData { expanded, .. }),
1121 ..
1122 }) => assert!(*expanded),
1123 other => panic!("expected ToolCall with Some(result), got {other:?}"),
1124 }
1125 let _ = app.handle_key(ctrl('e'));
1126 match app.blocks.last() {
1127 Some(TranscriptBlock::ToolCall {
1128 result: Some(ToolResultData { expanded, .. }),
1129 ..
1130 }) => assert!(!*expanded),
1131 other => panic!("expected ToolCall with Some(result), got {other:?}"),
1132 }
1133 }
1134
1135 #[test]
1138 fn enter_moves_input_to_blocks() {
1139 let mut app = App::new();
1140 app.screen = AppScreen::Chat;
1141 app.set_input("hello");
1142 let action = app.handle_key(key(KeyCode::Enter));
1143 assert!(app.input().is_empty());
1144 assert!(app
1145 .blocks
1146 .iter()
1147 .any(|b| matches!(b, TranscriptBlock::User { text } if text == "hello")));
1148 assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "hello"));
1149 }
1150
1151 #[test]
1152 fn enter_starts_a_turn() {
1153 let mut app = App::new();
1154 app.screen = AppScreen::Chat;
1155 app.set_input("hi");
1156 let _ = app.handle_key(key(KeyCode::Enter));
1157 assert!(app.turn.running);
1158 assert_eq!(app.turn_count, 1);
1159 }
1160
1161 #[test]
1162 fn esc_clears_buffer_without_quitting() {
1163 let mut app = App::new();
1164 app.screen = AppScreen::Chat;
1165 app.set_input("partial");
1166 let _ = app.handle_key(key(KeyCode::Esc));
1167 assert!(!app.should_quit);
1168 assert!(app.input().is_empty());
1169 }
1170
1171 #[test]
1172 fn char_appends_to_input() {
1173 let mut app = App::new();
1174 app.screen = AppScreen::Chat;
1175 let _ = app.handle_key(key(KeyCode::Char('h')));
1176 let _ = app.handle_key(key(KeyCode::Char('i')));
1177 assert_eq!(app.input(), "hi");
1178 }
1179
1180 #[test]
1181 fn backspace_removes_last_char() {
1182 let mut app = App::new();
1183 app.screen = AppScreen::Chat;
1184 app.set_input("hello");
1185 let _ = app.handle_key(key(KeyCode::Backspace));
1186 assert_eq!(app.input(), "hell");
1187 }
1188
1189 #[test]
1195 fn plain_up_does_not_scroll_transcript() {
1196 let mut app = App::new();
1197 app.screen = AppScreen::Chat;
1198 for i in 0..30 {
1199 app.blocks.push(TranscriptBlock::System {
1200 text: format!("msg {i}"),
1201 });
1202 }
1203 let _ = app.handle_key(key(KeyCode::Up));
1204 assert_eq!(app.scroll_offset, 0);
1205 let _ = app.handle_key(key(KeyCode::Down));
1206 assert_eq!(app.scroll_offset, 0);
1207 }
1208
1209 #[test]
1210 fn shift_up_increases_scroll_offset() {
1211 let mut app = App::new();
1212 app.screen = AppScreen::Chat;
1213 for i in 0..30 {
1214 app.blocks.push(TranscriptBlock::System {
1215 text: format!("msg {i}"),
1216 });
1217 }
1218 let _ = app.handle_key(shift(KeyCode::Up));
1219 assert_eq!(app.scroll_offset, 1);
1220 let _ = app.handle_key(shift(KeyCode::Up));
1221 assert_eq!(app.scroll_offset, 2);
1222 }
1223
1224 #[test]
1225 fn shift_down_stops_at_zero() {
1226 let mut app = App::new();
1227 app.screen = AppScreen::Chat;
1228 app.scroll_offset = 2;
1229 let _ = app.handle_key(shift(KeyCode::Down));
1230 let _ = app.handle_key(shift(KeyCode::Down));
1231 let _ = app.handle_key(shift(KeyCode::Down));
1232 assert_eq!(app.scroll_offset, 0);
1233 }
1234
1235 #[test]
1236 fn page_up_scrolls_by_ten() {
1237 let mut app = App::new();
1238 app.screen = AppScreen::Chat;
1239 let _ = app.handle_key(key(KeyCode::PageUp));
1240 assert_eq!(app.scroll_offset, 10);
1241 }
1242
1243 #[test]
1244 fn page_down_scrolls_by_ten() {
1245 let mut app = App::new();
1246 app.screen = AppScreen::Chat;
1247 app.scroll_offset = 15;
1248 let _ = app.handle_key(key(KeyCode::PageDown));
1249 assert_eq!(app.scroll_offset, 5);
1250 }
1251
1252 #[test]
1254 fn page_up_scrolls_even_when_buffer_not_empty() {
1255 let mut app = App::new();
1256 app.screen = AppScreen::Chat;
1257 app.set_input("typing");
1258 let _ = app.handle_key(key(KeyCode::PageUp));
1259 assert_eq!(app.scroll_offset, 10);
1260 }
1261
1262 #[test]
1276 fn plan_review_y_dispatches_confirm_plan_action() {
1277 use crate::tui::ui::modal::Modal;
1278 let mut app = App::new();
1279 app.screen = AppScreen::Chat;
1280 app.modals.push(Modal::PlanReview {
1281 plan_text: "do".into(),
1282 tool_calls: vec![],
1283 edited_text: None,
1284 });
1285 let action = app.handle_key(key(KeyCode::Char('y')));
1286 assert!(matches!(action, Some(UserAction::ConfirmPlan)));
1287 assert!(app.modals.is_empty());
1289 }
1290
1291 #[test]
1292 fn plan_review_n_dispatches_reject_plan_action() {
1293 use crate::tui::ui::modal::Modal;
1294 let mut app = App::new();
1295 app.screen = AppScreen::Chat;
1296 app.modals.push(Modal::PlanReview {
1297 plan_text: "do".into(),
1298 tool_calls: vec![],
1299 edited_text: None,
1300 });
1301 let action = app.handle_key(key(KeyCode::Char('n')));
1302 match action {
1303 Some(UserAction::RejectPlan(reason)) => assert_eq!(reason, "user rejected"),
1304 other => panic!("expected RejectPlan, got {other:?}"),
1305 }
1306 assert!(app.modals.is_empty());
1307 }
1308
1309 #[test]
1310 fn plan_review_e_copies_text_to_input_and_closes_modal() {
1311 use crate::tui::ui::modal::Modal;
1312 let mut app = App::new();
1313 app.screen = AppScreen::Chat;
1314 app.modals.push(Modal::PlanReview {
1315 plan_text: "edit me please".into(),
1316 tool_calls: vec![],
1317 edited_text: None,
1318 });
1319 let action = app.handle_key(key(KeyCode::Char('e')));
1320 assert!(action.is_none());
1321 assert_eq!(app.input(), "edit me please");
1322 assert_eq!(app.prompt.mode, InputMode::Prompt);
1323 assert!(app.modals.is_empty());
1324 }
1325
1326 #[test]
1328 fn esc_first_press_closes_modal_not_quits() {
1329 use crate::tui::ui::modal::Modal;
1330 let mut app = App::new();
1331 app.screen = AppScreen::Chat;
1332 app.modals.push(Modal::Help);
1333 let _ = app.handle_key(key(KeyCode::Esc));
1334 assert!(app.modals.is_empty());
1335 assert!(!app.should_quit);
1336 }
1337
1338 #[test]
1341 fn esc_first_press_clears_input_when_modal_empty_and_buffer_set() {
1342 let mut app = App::new();
1343 app.screen = AppScreen::Chat;
1344 app.set_input("partial");
1345 let _ = app.handle_key(key(KeyCode::Esc));
1346 assert!(!app.should_quit);
1347 assert!(app.input().is_empty());
1348 }
1349
1350 #[test]
1353 fn esc_does_not_quit_after_double_press_when_idle() {
1354 let mut app = App::new();
1355 app.screen = AppScreen::Chat;
1356 let _ = app.handle_key(key(KeyCode::Esc));
1357 let _ = app.handle_key(key(KeyCode::Esc));
1358 assert!(!app.should_quit);
1359 }
1360
1361 #[test]
1364 fn ctrl_c_first_press_during_turn_dispatches_interrupt() {
1365 let mut app = App::new();
1366 app.screen = AppScreen::Chat;
1367 app.turn.start();
1368 let action = app.handle_key(ctrl('c'));
1369 assert!(matches!(action, Some(UserAction::Interrupt)));
1370 assert!(app.blocks.iter().any(|b| matches!(b,
1371 TranscriptBlock::System { text } if text.contains("Interrupting"))));
1372 assert!(!app.should_quit);
1373 }
1374
1375 #[test]
1378 fn ctrl_c_first_press_idle_pushes_warning_then_exits_on_second() {
1379 let mut app = App::new();
1380 app.screen = AppScreen::Chat;
1381 let _ = app.handle_key(ctrl('c'));
1382 assert!(!app.should_quit);
1383 assert!(app.blocks.iter().any(|b| matches!(b,
1384 TranscriptBlock::System { text } if text.contains("Press Ctrl+C again"))));
1385 let _ = app.handle_key(ctrl('c'));
1386 assert!(app.should_quit);
1387 }
1388
1389 #[test]
1392 fn ctrl_c_double_press_within_window_quits() {
1393 let mut app = App::new();
1394 app.screen = AppScreen::Chat;
1395 app.turn.start();
1396 let _ = app.handle_key(ctrl('c'));
1397 let _ = app.handle_key(ctrl('c'));
1399 assert!(app.should_quit);
1400 }
1401
1402 #[test]
1405 fn ctrl_c_outside_window_resets_counter() {
1406 use std::time::Instant;
1407 let mut app = App::new();
1408 app.screen = AppScreen::Chat;
1409 app.double_press.last_ctrl_c_at = Some(Instant::now() - Duration::from_secs(60));
1411 let action = app.handle_key(ctrl('c'));
1412 assert!(action.is_none());
1414 assert!(!app.should_quit);
1415 assert!(app.blocks.iter().any(|b| matches!(b,
1416 TranscriptBlock::System { text } if text.contains("Press Ctrl+C again"))));
1417 }
1418}
1419
1420#[cfg(test)]
1425mod prompt_input_tests {
1426 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1427
1428 use crate::tui::app::{App, AppScreen, InputMode, HISTORY_CAPACITY};
1429 use crate::tui::input_state::strip_history_prefix;
1430
1431 fn k(code: KeyCode) -> KeyEvent {
1432 KeyEvent::new(code, KeyModifiers::NONE)
1433 }
1434
1435 fn shift(code: KeyCode) -> KeyEvent {
1436 KeyEvent::new(code, KeyModifiers::SHIFT)
1437 }
1438
1439 fn alt(code: KeyCode) -> KeyEvent {
1440 KeyEvent::new(code, KeyModifiers::ALT)
1441 }
1442
1443 fn ctrl(c: char) -> KeyEvent {
1444 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1445 }
1446
1447 fn fresh_app() -> App {
1448 let mut app = App::new();
1449 app.screen = AppScreen::Chat;
1450 app
1451 }
1452
1453 #[test]
1456 fn shift_tab_cycles_modes() {
1457 let mut app = fresh_app();
1458 assert_eq!(app.prompt.mode, InputMode::Prompt);
1459 let _ = app.handle_key(k(KeyCode::BackTab));
1460 assert_eq!(app.prompt.mode, InputMode::Bash);
1461 let _ = app.handle_key(k(KeyCode::BackTab));
1462 assert_eq!(app.prompt.mode, InputMode::Note);
1463 let _ = app.handle_key(k(KeyCode::BackTab));
1464 assert_eq!(app.prompt.mode, InputMode::Prompt);
1465 }
1466
1467 #[test]
1470 fn leading_bang_enters_bash_mode_when_buffer_empty() {
1471 let mut app = fresh_app();
1472 let _ = app.handle_key(k(KeyCode::Char('!')));
1473 assert_eq!(app.prompt.mode, InputMode::Bash);
1474 assert!(app.prompt.buffer.is_empty());
1476 }
1477
1478 #[test]
1479 fn leading_hash_enters_note_mode() {
1480 let mut app = fresh_app();
1481 let _ = app.handle_key(k(KeyCode::Char('#')));
1482 assert_eq!(app.prompt.mode, InputMode::Note);
1483 assert!(app.prompt.buffer.is_empty());
1484 }
1485
1486 #[test]
1487 fn leading_slash_enters_command_mode() {
1488 let mut app = fresh_app();
1489 let _ = app.handle_key(k(KeyCode::Char('/')));
1490 assert_eq!(app.prompt.mode, InputMode::Command);
1491 assert!(app.prompt.buffer.is_empty());
1492 }
1493
1494 #[test]
1495 fn leading_bang_after_existing_text_is_just_a_char() {
1496 let mut app = fresh_app();
1497 let _ = app.handle_key(k(KeyCode::Char('h')));
1498 let _ = app.handle_key(k(KeyCode::Char('!')));
1499 assert_eq!(app.prompt.mode, InputMode::Prompt);
1500 assert_eq!(app.prompt.buffer, "h!");
1501 }
1502
1503 #[test]
1506 fn backspace_on_empty_exits_to_prompt_mode() {
1507 let mut app = fresh_app();
1508 let _ = app.handle_key(k(KeyCode::Char('!')));
1509 assert_eq!(app.prompt.mode, InputMode::Bash);
1510 let _ = app.handle_key(k(KeyCode::Backspace));
1511 assert_eq!(app.prompt.mode, InputMode::Prompt);
1512 }
1513
1514 #[test]
1517 fn cursor_left_right_moves_within_buffer() {
1518 let mut app = fresh_app();
1519 for c in "abc".chars() {
1520 let _ = app.handle_key(k(KeyCode::Char(c)));
1521 }
1522 assert_eq!(app.prompt.cursor, 3);
1523 let _ = app.handle_key(k(KeyCode::Left));
1524 assert_eq!(app.prompt.cursor, 2);
1525 let _ = app.handle_key(k(KeyCode::Left));
1526 assert_eq!(app.prompt.cursor, 1);
1527 let _ = app.handle_key(k(KeyCode::Right));
1528 assert_eq!(app.prompt.cursor, 2);
1529 }
1530
1531 #[test]
1532 fn cursor_handles_multibyte_chars() {
1533 let mut app = fresh_app();
1534 for c in "你好".chars() {
1535 let _ = app.handle_key(k(KeyCode::Char(c)));
1536 }
1537 assert_eq!(app.prompt.cursor, 6);
1539 let _ = app.handle_key(k(KeyCode::Left));
1540 assert_eq!(app.prompt.cursor, 3);
1541 let _ = app.handle_key(k(KeyCode::Backspace));
1542 assert_eq!(app.prompt.buffer, "好");
1543 }
1544
1545 #[test]
1546 fn insert_at_cursor_not_just_end() {
1547 let mut app = fresh_app();
1548 for c in "ac".chars() {
1549 let _ = app.handle_key(k(KeyCode::Char(c)));
1550 }
1551 let _ = app.handle_key(k(KeyCode::Left));
1552 let _ = app.handle_key(k(KeyCode::Char('b')));
1553 assert_eq!(app.prompt.buffer, "abc");
1554 }
1555
1556 #[test]
1559 fn shift_enter_inserts_newline_at_cursor() {
1560 let mut app = fresh_app();
1561 let _ = app.handle_key(k(KeyCode::Char('a')));
1562 let _ = app.handle_key(shift(KeyCode::Enter));
1563 let _ = app.handle_key(k(KeyCode::Char('b')));
1564 assert_eq!(app.prompt.buffer, "a\nb");
1565 }
1566
1567 #[test]
1568 fn alt_enter_also_inserts_newline() {
1569 let mut app = fresh_app();
1570 let _ = app.handle_key(k(KeyCode::Char('a')));
1571 let _ = app.handle_key(alt(KeyCode::Enter));
1572 let _ = app.handle_key(k(KeyCode::Char('b')));
1573 assert_eq!(app.prompt.buffer, "a\nb");
1574 }
1575
1576 #[test]
1579 fn history_up_down_navigates_records() {
1580 let mut app = fresh_app();
1581 app.set_input("first");
1583 let _ = app.handle_key(k(KeyCode::Enter));
1584 app.set_input("second");
1585 let _ = app.handle_key(k(KeyCode::Enter));
1586 assert_eq!(app.prompt.history.len(), 2);
1587
1588 let _ = app.handle_key(k(KeyCode::Up));
1589 assert_eq!(app.prompt.buffer, "second");
1590 let _ = app.handle_key(k(KeyCode::Up));
1591 assert_eq!(app.prompt.buffer, "first");
1592 let _ = app.handle_key(k(KeyCode::Down));
1593 assert_eq!(app.prompt.buffer, "second");
1594 let _ = app.handle_key(k(KeyCode::Down));
1595 assert!(app.prompt.buffer.is_empty());
1597 }
1598
1599 #[test]
1602 fn history_up_saves_draft_and_restores_on_overflow() {
1603 let mut app = fresh_app();
1604 app.set_input("alpha");
1605 let _ = app.handle_key(k(KeyCode::Enter));
1606 let _ = app.handle_key(k(KeyCode::Up));
1608 assert_eq!(app.prompt.buffer, "alpha");
1609 let _ = app.handle_key(k(KeyCode::Down));
1610 assert!(app.prompt.buffer.is_empty());
1611 }
1612
1613 #[test]
1614 fn history_preserves_mode_prefix() {
1615 let mut app = fresh_app();
1616 let _ = app.handle_key(k(KeyCode::Char('!')));
1618 for c in "echo hi".chars() {
1619 let _ = app.handle_key(k(KeyCode::Char(c)));
1620 }
1621 let _ = app.handle_key(k(KeyCode::Enter));
1622 assert_eq!(app.prompt.mode, InputMode::Prompt);
1623 let _ = app.handle_key(k(KeyCode::Up));
1625 assert_eq!(app.prompt.mode, InputMode::Bash);
1626 assert_eq!(app.prompt.buffer, "echo hi");
1627 }
1628
1629 #[test]
1630 fn history_capacity_truncates_oldest() {
1631 let mut app = fresh_app();
1632 for i in 0..(HISTORY_CAPACITY + 5) {
1633 app.set_input(format!("msg{i}"));
1634 let _ = app.handle_key(k(KeyCode::Enter));
1635 }
1636 assert_eq!(app.prompt.history.len(), HISTORY_CAPACITY);
1637 assert!(!app.prompt.history.iter().any(|h| h == "msg0"));
1639 }
1640
1641 #[test]
1644 fn submit_in_bash_mode_dispatches_run_shell() {
1645 use crate::tui::events::UserAction;
1646 let mut app = fresh_app();
1647 let _ = app.handle_key(k(KeyCode::Char('!')));
1648 for c in "ls".chars() {
1649 let _ = app.handle_key(k(KeyCode::Char(c)));
1650 }
1651 let action = app.handle_key(k(KeyCode::Enter));
1652 assert!(matches!(action, Some(UserAction::RunShell(s)) if s == "ls"));
1653 assert!(app.prompt.buffer.is_empty());
1654 assert_eq!(app.prompt.mode, InputMode::Prompt);
1655 }
1656
1657 #[test]
1660 fn submit_in_note_mode_appends_system_block() {
1661 use crate::tui::app::TranscriptBlock;
1662 let mut app = fresh_app();
1663 let _ = app.handle_key(k(KeyCode::Char('#')));
1664 for c in "remember this".chars() {
1665 let _ = app.handle_key(k(KeyCode::Char(c)));
1666 }
1667 let action = app.handle_key(k(KeyCode::Enter));
1668 assert!(action.is_none());
1670 assert!(app
1671 .blocks
1672 .iter()
1673 .any(|b| matches!(b, TranscriptBlock::System { text }
1674 if text.contains("remember this"))));
1675 }
1676
1677 #[test]
1678 fn submit_in_command_mode_dispatches_to_registry() {
1679 let mut app = fresh_app();
1682 let _ = app.handle_key(k(KeyCode::Char('/')));
1683 for c in "help".chars() {
1684 let _ = app.handle_key(k(KeyCode::Char(c)));
1685 }
1686 let action = app.handle_key(k(KeyCode::Enter));
1687 assert!(action.is_none());
1688 assert_eq!(app.modals.last(), Some(&crate::tui::ui::modal::Modal::Help));
1690 assert!(app.prompt.buffer.is_empty());
1692 assert_eq!(app.prompt.mode, InputMode::Prompt);
1693 }
1694
1695 #[test]
1698 fn submit_clears_buffer_and_resets_mode() {
1699 let mut app = fresh_app();
1700 let _ = app.handle_key(k(KeyCode::Char('!')));
1701 let _ = app.handle_key(k(KeyCode::Char('x')));
1702 let _ = app.handle_key(k(KeyCode::Enter));
1703 assert!(app.prompt.buffer.is_empty());
1704 assert_eq!(app.prompt.cursor, 0);
1705 assert_eq!(app.prompt.mode, InputMode::Prompt);
1706 assert!(app.prompt.history_idx.is_none());
1707 }
1708
1709 #[test]
1712 fn home_end_target_current_line_only() {
1713 let mut app = fresh_app();
1714 app.set_input("ab\ncd");
1715 app.prompt.cursor = 4; let _ = app.handle_key(k(KeyCode::Home));
1718 assert_eq!(app.prompt.cursor, 3); let _ = app.handle_key(k(KeyCode::End));
1720 assert_eq!(app.prompt.cursor, 5); }
1722
1723 #[test]
1726 fn ctrl_e_with_empty_buffer_toggles_tool_result() {
1727 use crate::tui::app::{ToolResultData, TranscriptBlock};
1728 use crate::tui::events::UiEvent;
1729 let mut app = fresh_app();
1730 app.handle_ui_event(UiEvent::ToolResult {
1731 id: "1".into(),
1732 name: "read_file".into(),
1733 output: "ok".into(),
1734 success: true,
1735 });
1736 let _ = app.handle_key(ctrl('e'));
1737 match app.blocks.last() {
1738 Some(TranscriptBlock::ToolCall {
1739 result: Some(ToolResultData { expanded, .. }),
1740 ..
1741 }) => assert!(*expanded),
1742 other => panic!("expected ToolCall with Some(result), got {other:?}"),
1743 }
1744 }
1745
1746 #[test]
1747 fn ctrl_e_with_text_moves_to_end_of_line() {
1748 let mut app = fresh_app();
1749 app.set_input("hello");
1750 app.prompt.cursor = 1;
1751 let _ = app.handle_key(ctrl('e'));
1752 assert_eq!(app.prompt.cursor, 5);
1753 }
1754
1755 #[test]
1756 fn ctrl_a_moves_to_line_start() {
1757 let mut app = fresh_app();
1758 app.set_input("hello");
1759 let _ = app.handle_key(ctrl('a'));
1760 assert_eq!(app.prompt.cursor, 0);
1761 }
1762
1763 #[test]
1766 fn history_down_with_no_walk_in_progress_is_noop() {
1767 let mut app = fresh_app();
1768 let _ = app.handle_key(k(KeyCode::Down));
1770 assert!(app.prompt.history_idx.is_none());
1771 }
1772
1773 #[test]
1776 fn strip_history_prefix_recognises_all_modes() {
1777 assert_eq!(strip_history_prefix("!ls").0, InputMode::Bash);
1778 assert_eq!(strip_history_prefix("#note").0, InputMode::Note);
1779 assert_eq!(strip_history_prefix("/cmd").0, InputMode::Command);
1780 assert_eq!(strip_history_prefix("hello").0, InputMode::Prompt);
1781 assert_eq!(strip_history_prefix("!ls").1, "ls");
1782 }
1783}
1784
1785#[cfg(test)]
1788mod atfile_tests {
1789 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1790
1791 use crate::tui::app::{App, InputMode, MAX_ATFILE_SUGGESTIONS};
1792 use crate::tui::completion::glob_workspace_files;
1793
1794 fn k(code: KeyCode) -> KeyEvent {
1795 KeyEvent::new(code, KeyModifiers::NONE)
1796 }
1797
1798 #[test]
1799 fn atfile_mode_triggered_by_at_in_prompt_mode() {
1800 let mut app = App::new();
1801 assert_eq!(app.prompt.mode, InputMode::Prompt);
1802 app.handle_char_input('@');
1803 assert_eq!(app.prompt.mode, InputMode::AtFile);
1804 assert!(app.prompt.buffer.ends_with('@'));
1805 }
1806
1807 #[test]
1808 fn atfile_mode_not_triggered_in_bash_mode() {
1809 let mut app = App::new();
1810 app.prompt.mode = InputMode::Bash;
1811 app.handle_char_input('@');
1812 assert_eq!(app.prompt.mode, InputMode::Bash);
1813 }
1814
1815 #[test]
1816 fn atfile_mode_not_triggered_in_command_mode() {
1817 let mut app = App::new();
1818 app.prompt.mode = InputMode::Command;
1819 app.handle_char_input('@');
1820 assert_eq!(app.prompt.mode, InputMode::Command);
1821 }
1822
1823 #[test]
1824 fn glob_workspace_files_filters_by_query_prefix() {
1825 let results = glob_workspace_files("Cargo");
1828 assert!(results.len() <= MAX_ATFILE_SUGGESTIONS);
1830 for r in &results {
1832 assert!(r.to_lowercase().contains("cargo"), "unexpected result: {r}");
1833 }
1834 }
1835
1836 #[test]
1837 fn glob_workspace_files_returns_at_most_12() {
1838 let results = glob_workspace_files("");
1839 assert!(results.len() <= MAX_ATFILE_SUGGESTIONS);
1840 }
1841
1842 #[test]
1843 fn atfile_backspace_on_empty_query_exits_mode_and_deletes_at() {
1844 let mut app = App::new();
1845 app.handle_char_input('h');
1847 app.handle_char_input('i');
1848 app.handle_char_input('@');
1849 assert_eq!(app.prompt.mode, InputMode::AtFile);
1850 assert_eq!(app.prompt.buffer, "hi@");
1851
1852 app.handle_atfile_key(k(KeyCode::Backspace));
1854 assert_eq!(app.prompt.mode, InputMode::Prompt);
1855 assert_eq!(app.prompt.buffer, "hi");
1856 }
1857
1858 #[test]
1859 fn atfile_enter_inserts_selected_path_and_exits() {
1860 let mut app = App::new();
1861 app.handle_char_input('@');
1862 assert_eq!(app.prompt.mode, InputMode::AtFile);
1863
1864 app.atfile_suggestions = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
1866 app.atfile_selected = Some(0);
1867
1868 app.handle_atfile_key(k(KeyCode::Enter));
1870 assert_eq!(app.prompt.mode, InputMode::Prompt);
1871 assert!(
1872 app.prompt.buffer.ends_with("@src/lib.rs"),
1873 "buffer was: {}",
1874 app.prompt.buffer
1875 );
1876 }
1877
1878 #[test]
1879 fn atfile_esc_cancels_and_preserves_at_query() {
1880 let mut app = App::new();
1881 app.handle_char_input('t');
1882 app.handle_char_input('e');
1883 app.handle_char_input('s');
1884 app.handle_char_input('t');
1885 app.handle_char_input(' ');
1886 app.handle_char_input('@');
1887 app.handle_atfile_key(k(KeyCode::Char('s')));
1889 app.handle_atfile_key(k(KeyCode::Char('r')));
1890 app.handle_atfile_key(k(KeyCode::Char('c')));
1891
1892 assert_eq!(app.prompt.mode, InputMode::AtFile);
1893 let buf_before = app.prompt.buffer.clone();
1894
1895 app.handle_atfile_key(k(KeyCode::Esc));
1897 assert_eq!(app.prompt.mode, InputMode::Prompt);
1898 assert_eq!(app.prompt.buffer, buf_before);
1899 assert!(app.atfile_suggestions.is_empty());
1901 }
1902
1903 #[test]
1904 fn atfile_up_down_navigation() {
1905 let mut app = App::new();
1906 app.handle_char_input('@');
1907 app.atfile_suggestions = vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()];
1908 app.atfile_selected = None;
1909
1910 app.handle_atfile_key(k(KeyCode::Down));
1912 assert_eq!(app.atfile_selected, Some(0));
1913
1914 app.handle_atfile_key(k(KeyCode::Down));
1916 assert_eq!(app.atfile_selected, Some(1));
1917
1918 app.handle_atfile_key(k(KeyCode::Up));
1920 assert_eq!(app.atfile_selected, Some(0));
1921
1922 app.handle_atfile_key(k(KeyCode::Up));
1924 assert_eq!(app.atfile_selected, None);
1925 }
1926}
1927
1928#[cfg(test)]
1931mod hsearch_tests {
1932 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1933
1934 use crate::tui::app::{App, InputMode, MAX_HSEARCH_RESULTS};
1935 use crate::tui::completion::search_history;
1936
1937 fn k(code: KeyCode) -> KeyEvent {
1938 KeyEvent::new(code, KeyModifiers::NONE)
1939 }
1940
1941 fn ctrl(code: KeyCode) -> KeyEvent {
1942 KeyEvent::new(code, KeyModifiers::CONTROL)
1943 }
1944
1945 fn history_app(entries: &[&str]) -> App {
1946 let mut app = App::new();
1947 for e in entries {
1948 app.prompt.history.push(e.to_string());
1949 }
1950 app
1951 }
1952
1953 #[test]
1956 fn history_search_empty_query_returns_all_reversed() {
1957 let h = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1958 let r = search_history(&h, "");
1959 assert_eq!(r, vec![2, 1, 0]);
1961 }
1962
1963 #[test]
1964 fn history_search_prefix_match_ranked_first() {
1965 let h = vec![
1966 "foo bar".to_string(),
1967 "zz foo".to_string(),
1968 "foobar".to_string(),
1969 ];
1970 let r = search_history(&h, "foo");
1971 assert!(r.iter().position(|&x| x == 2) < r.iter().position(|&x| x == 1));
1974 assert!(r.iter().position(|&x| x == 0) < r.iter().position(|&x| x == 1));
1975 }
1976
1977 #[test]
1978 fn history_search_case_insensitive() {
1979 let h = vec!["Hello World".to_string(), "goodbye".to_string()];
1980 let r = search_history(&h, "hello");
1981 assert!(r.contains(&0));
1982 assert!(!r.contains(&1));
1983 }
1984
1985 #[test]
1986 fn history_search_returns_at_most_12() {
1987 let h: Vec<String> = (0..20).map(|i| format!("entry {i}")).collect();
1988 let r = search_history(&h, "entry");
1989 assert!(r.len() <= MAX_HSEARCH_RESULTS);
1990 }
1991
1992 #[test]
1995 fn ctrl_r_in_prompt_mode_enters_history_search() {
1996 let mut app = history_app(&["hello", "world"]);
1997 assert_eq!(app.prompt.mode, InputMode::Prompt);
1998 app.handle_key(ctrl(KeyCode::Char('r')));
1999 assert_eq!(app.prompt.mode, InputMode::HistorySearch);
2000 assert_eq!(app.hsearch_matches.len(), 2);
2002 }
2003
2004 #[test]
2005 fn ctrl_r_in_bash_mode_no_op() {
2006 let mut app = history_app(&["hello"]);
2007 app.prompt.mode = InputMode::Bash;
2008 app.handle_key(ctrl(KeyCode::Char('r')));
2009 assert_eq!(app.prompt.mode, InputMode::Bash);
2011 }
2012
2013 #[test]
2014 fn history_search_enter_fills_buffer() {
2015 let mut app = history_app(&["cargo build", "cargo test"]);
2016 app.handle_key(ctrl(KeyCode::Char('r')));
2017 assert_eq!(app.prompt.mode, InputMode::HistorySearch);
2018 assert_eq!(app.hsearch_selected, 0);
2020 app.handle_history_search_key(k(KeyCode::Enter));
2022 assert_eq!(app.prompt.mode, InputMode::Prompt);
2023 assert_eq!(app.prompt.buffer, "cargo test");
2024 }
2025
2026 #[test]
2027 fn history_search_esc_cancels() {
2028 let mut app = history_app(&["hello"]);
2029 app.handle_key(ctrl(KeyCode::Char('r')));
2030 assert_eq!(app.prompt.mode, InputMode::HistorySearch);
2031 app.handle_history_search_key(k(KeyCode::Esc));
2032 assert_eq!(app.prompt.mode, InputMode::Prompt);
2033 assert!(app.prompt.buffer.is_empty());
2035 }
2036
2037 #[test]
2038 fn history_search_backspace_on_empty_exits_mode() {
2039 let mut app = history_app(&["hello"]);
2040 app.handle_key(ctrl(KeyCode::Char('r')));
2041 assert_eq!(app.prompt.mode, InputMode::HistorySearch);
2042 assert!(app.hsearch_query.is_empty());
2043 app.handle_history_search_key(k(KeyCode::Backspace));
2045 assert_eq!(app.prompt.mode, InputMode::Prompt);
2046 }
2047}
2048
2049#[cfg(test)]
2052mod perm_tests {
2053 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2054
2055 use crate::tui::app::{App, PendingPermission};
2056 use crate::tui::events::UiEvent;
2057
2058 fn make_perm(tool: &str, args: &str) -> (App, tokio::sync::oneshot::Receiver<bool>) {
2059 let mut app = App::new();
2060 let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
2061 let req = crate::tui::events::PermissionRequest {
2062 tool_name: tool.to_string(),
2063 args_preview: args.to_string(),
2064 reply: tx,
2065 };
2066 app.set_pending_permission(req);
2067 (app, rx)
2068 }
2069
2070 fn k(code: KeyCode) -> KeyEvent {
2071 KeyEvent::new(code, KeyModifiers::NONE)
2072 }
2073
2074 #[test]
2075 fn pending_permission_set_and_stored() {
2076 let (app, _rx) = make_perm("run_shell", "ls -la");
2077 assert!(app.pending_permission.is_some());
2078 let p = app.pending_permission.as_ref().unwrap();
2079 assert_eq!(p.tool_name, "run_shell");
2080 assert_eq!(p.args_preview, "ls -la");
2081 }
2082
2083 #[tokio::test]
2084 async fn y_key_sends_true_and_clears_modal() {
2085 let (mut app, rx) = make_perm("run_shell", "ls");
2086 app.handle_permission_key(k(KeyCode::Char('y')));
2087 assert!(app.pending_permission.is_none());
2088 assert!(rx.await.unwrap());
2089 }
2090
2091 #[tokio::test]
2092 async fn n_key_sends_false_and_clears_modal() {
2093 let (mut app, rx) = make_perm("run_shell", "rm -rf /");
2094 app.handle_permission_key(k(KeyCode::Char('n')));
2095 assert!(app.pending_permission.is_none());
2096 assert!(!rx.await.unwrap());
2097 }
2098
2099 #[tokio::test]
2100 async fn esc_key_sends_false() {
2101 let (mut app, rx) = make_perm("write_file", "path=foo.txt");
2102 app.handle_permission_key(k(KeyCode::Esc));
2103 assert!(app.pending_permission.is_none());
2104 assert!(!rx.await.unwrap());
2105 }
2106
2107 #[tokio::test]
2108 async fn enter_key_sends_true() {
2109 let (mut app, rx) = make_perm("read_file", "path=foo.txt");
2110 app.handle_permission_key(k(KeyCode::Enter));
2111 assert!(app.pending_permission.is_none());
2112 assert!(rx.await.unwrap());
2113 }
2114
2115 #[tokio::test]
2116 async fn a_key_sends_true_and_adds_to_auto_allowed() {
2117 let (mut app, rx) = make_perm("run_shell", "cargo test");
2118 app.handle_permission_key(k(KeyCode::Char('a')));
2119 assert!(app.pending_permission.is_none());
2120 assert!(rx.await.unwrap());
2121 assert!(app.auto_allowed_tools.contains("run_shell"));
2122 }
2123
2124 #[tokio::test]
2125 async fn auto_allowed_tool_skips_modal() {
2126 let mut app = App::new();
2127 app.auto_allowed_tools.insert("run_shell".to_string());
2128 let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
2129 let req = crate::tui::events::PermissionRequest {
2130 tool_name: "run_shell".to_string(),
2131 args_preview: "cargo build".to_string(),
2132 reply: tx,
2133 };
2134 app.set_pending_permission(req);
2136 assert!(app.pending_permission.is_none());
2137 assert!(rx.await.unwrap());
2138 }
2139
2140 #[test]
2141 fn handle_key_routes_to_permission_when_pending() {
2142 let (tx, _rx) = tokio::sync::oneshot::channel::<bool>();
2144 let mut app = App::new();
2145 let req = crate::tui::events::PermissionRequest {
2146 tool_name: "write_file".to_string(),
2147 args_preview: "path=foo.rs".to_string(),
2148 reply: tx,
2149 };
2150 app.pending_permission = Some(PendingPermission {
2151 tool_name: req.tool_name,
2152 args_preview: req.args_preview,
2153 reply: req.reply,
2154 });
2155 assert!(app.pending_permission.is_some());
2156 app.handle_key(k(KeyCode::Char('n')));
2158 assert!(app.pending_permission.is_none());
2159 }
2160
2161 #[test]
2164 fn plan_mode_requested_event_sets_pending_flag() {
2165 use crate::tui::app::TranscriptBlock;
2166 let mut app = App::new();
2167 app.handle_ui_event(UiEvent::PlanModeRequested {
2168 reason: "This task is complex".into(),
2169 });
2170 assert!(app.plan_mode_request_pending);
2171 assert!(app.blocks.iter().any(|b| matches!(b,
2172 TranscriptBlock::PlanModeRequest { reason, approved: None }
2173 if reason.contains("complex"))));
2174 }
2175
2176 #[test]
2177 fn plan_mode_request_y_dispatches_approve_action() {
2178 use crate::tui::events::UserAction;
2179 let mut app = App::new();
2180 app.handle_ui_event(UiEvent::PlanModeRequested {
2181 reason: "need to plan".into(),
2182 });
2183 assert!(app.plan_mode_request_pending);
2184 let action = app.handle_key(k(KeyCode::Char('y')));
2185 assert!(!app.plan_mode_request_pending, "flag should be cleared");
2186 assert!(matches!(action, Some(UserAction::ApprovePlanMode)));
2187 }
2188
2189 #[test]
2190 fn plan_mode_request_n_dispatches_reject_action() {
2191 use crate::tui::events::UserAction;
2192 let mut app = App::new();
2193 app.handle_ui_event(UiEvent::PlanModeRequested {
2194 reason: "need to plan".into(),
2195 });
2196 let action = app.handle_key(k(KeyCode::Char('n')));
2197 assert!(!app.plan_mode_request_pending, "flag should be cleared");
2198 assert!(matches!(action, Some(UserAction::RejectPlanMode(r)) if r == "user skipped"));
2199 }
2200
2201 #[test]
2202 fn plan_mode_approved_event_marks_block() {
2203 use crate::tui::app::TranscriptBlock;
2204 let mut app = App::new();
2205 app.handle_ui_event(UiEvent::PlanModeRequested {
2206 reason: "complex".into(),
2207 });
2208 app.handle_ui_event(UiEvent::PlanModeApproved);
2209 assert!(!app.plan_mode_request_pending);
2210 assert!(app.blocks.iter().any(|b| matches!(
2211 b,
2212 TranscriptBlock::PlanModeRequest {
2213 approved: Some(true),
2214 ..
2215 }
2216 )));
2217 }
2218
2219 #[test]
2220 fn plan_mode_rejected_event_marks_block() {
2221 use crate::tui::app::TranscriptBlock;
2222 let mut app = App::new();
2223 app.handle_ui_event(UiEvent::PlanModeRequested {
2224 reason: "complex".into(),
2225 });
2226 app.handle_ui_event(UiEvent::PlanModeRejected {
2227 reason: "user skipped".into(),
2228 });
2229 assert!(!app.plan_mode_request_pending);
2230 assert!(app.blocks.iter().any(|b| matches!(
2231 b,
2232 TranscriptBlock::PlanModeRequest {
2233 approved: Some(false),
2234 ..
2235 }
2236 )));
2237 }
2238}