Skip to main content

recursive/tui/app/
commands.rs

1//! Keyboard dispatch, modal handlers, @file autocomplete, history search,
2//! and slash-command execution.
3
4use 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    /// Process one key event. Returns an optional [`UserAction`] that
14    /// the caller must forward to the backend worker.
15    pub fn handle_key(&mut self, key: KeyEvent) -> Option<UserAction> {
16        // ── Ctrl+C: highest priority, double-press promotes to exit
17        // (Goal 147 §5). Modals + buffer + turn state all decide what
18        // the *first* press does; the second press inside the window
19        // always quits.
20        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
21            return self.handle_ctrl_c();
22        }
23
24        // ── Goal-161: permission modal ───────────────────────────────
25        // When a tool-permission request is pending, all keys go to the
26        // permission dialog. Y/Enter allow, N/Esc deny.
27        if self.pending_permission.is_some() {
28            return self.handle_permission_key(key);
29        }
30
31        // ── Goal-202: plan-mode pre-confirmation ──────────────────────
32        // When the agent has called `request_plan_mode`, y/Enter approve
33        // and n/Esc reject — just like the plan approval banner.
34        if self.plan_mode_request_pending {
35            return self.handle_plan_mode_request_key(key);
36        }
37
38        // ── Modal stack ──────────────────────────────────────────────
39        // Goal-146: when any modal is on the stack, it owns the key
40        // events. Modals may produce UserActions (Goal-147 added the
41        // PlanReview y/n/Esc paths that send ConfirmPlan / RejectPlan
42        // to the backend).
43        if !self.modals.is_empty() {
44            return self.handle_modal_key_action(key);
45        }
46
47        // ── Ctrl+E: contextual ───────────────────────────────────────
48        // When the input buffer is non-empty, Ctrl+E behaves as
49        // "move to end-of-line" inside the input. When the buffer
50        // is empty, Ctrl+E falls back to Goal-144's "expand the
51        // most recent ToolResult" behaviour. This is the conflict
52        // resolution the goal calls for in §10.
53        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        // ── Ctrl+A: line-start in the input box ──────────────────────
63        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('a') {
64            self.prompt.move_home();
65            return None;
66        }
67
68        // ── Ctrl+B / Ctrl+F / Ctrl+P / Ctrl+N: emacs-style cursor motion
69        // ─────────────────────────────────────────────
70        // The previous binding for B/F was "page-scroll the
71        // transcript by 10 lines" as a fallback for terminals
72        // without reliable PageUp/PageDown. macOS users on
73        // iTerm2 / Terminal.app / WezTerm all deliver PageUp and
74        // PageDown properly today, so we re-purpose B/F for the
75        // emacs / readline convention (cursor left / right). P/N
76        // are emacs previous-line / next-line. The transcript
77        // scroll still works via PageUp/PageDown, Shift+↑/↓,
78        // and the mouse wheel.
79        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        // ── Ctrl+R: history search (Goal 160) ────────────────────────
97        // In Prompt mode, Ctrl+R enters HistorySearch. In
98        // HistorySearch mode, a second Ctrl+R moves down one match
99        // (bash-compatible). In other modes, it is a no-op.
100        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                    // Cycle to next match.
108                    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        // ── Shift+Tab: cycle modes ───────────────────────────────────
119        if key.code == KeyCode::BackTab {
120            self.prompt.mode = self.prompt.mode.cycle_next();
121            return None;
122        }
123
124        // ── History-search navigation (Goal 160) ─────────────────────
125        if self.prompt.mode == InputMode::HistorySearch {
126            return self.handle_history_search_key(key);
127        }
128
129        // ── @file autocomplete navigation (Goal 158) ─────────────────
130        // When in AtFile mode, route navigation keys to the file
131        // completion popup before anything else.
132        if self.prompt.mode == InputMode::AtFile {
133            return self.handle_atfile_key(key);
134        }
135
136        // ── Command-menu navigation (Goal 146) ───────────────────────
137        // Intercept Up/Down/Tab/Enter when the user is composing a
138        // slash command so the popup behaves like an autocomplete
139        // menu rather than scrolling the transcript / submitting.
140        if self.prompt.mode == InputMode::Command {
141            if let Some(action) = self.handle_command_menu_key(key) {
142                return action;
143            }
144        }
145
146        // ── Chat screen ──────────────────────────────────────────────
147        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                // Shift+Enter / Alt+Enter / Ctrl+Enter all insert a
154                // literal newline instead of submitting. macOS
155                // Terminal.app often intercepts Option+Enter before
156                // the app sees it, so we offer Ctrl+Enter as a
157                // terminal-independent alternative.
158                self.prompt.insert_char('\n');
159                None
160            }
161            // Ctrl+J (emacs "line feed"): insert a newline, never
162            // submit. Bound separately from `Enter` because
163            // crossterm delivers Ctrl+J as a Char('j') keypress
164            // with the CONTROL modifier set, not as KeyCode::Enter.
165            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            // Transcript scrolling — checked **before** history walk
171            // because Shift+↑/↓ should win even when the buffer is
172            // empty and history exists (otherwise the
173            // `should_walk_history_*` guard would silently consume
174            // the keypress for history navigation). Goal 150 follow-
175            // up: user reported scroll keys still drove the input
176            // box, root cause was this ordering.
177            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                    // Empty buffer in a non-Prompt mode: drop back to
212                    // Prompt rather than no-op. This is how the user
213                    // exits a mode they entered by accident.
214                    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    /// Goal-147: dispatch the Esc key when no modal is active.
246    ///
247    /// Order of resolution:
248    ///   1. Buffer non-empty → clear it and reset to Prompt mode.
249    ///   2. A turn is running → emit `UserAction::Interrupt`, push a
250    ///      System block, and start the double-press window.
251    ///   3. Otherwise → no-op. **Esc never quits** from the chat
252    ///      screen (Goal 147). Quitting is owned by `Ctrl+C×2`,
253    ///      `Ctrl+D`, `/exit`, or `q` inside a modal.
254    ///
255    /// The double-press window is tracked but unused for Esc — Esc
256    /// has no escalation path; we update the timestamp anyway so
257    /// future enhancements can read it without re-plumbing.
258    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        // Step 1: non-empty buffer or non-Prompt mode → clear.
270        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        // Step 2: in-flight turn → interrupt.
279        if self.turn.running {
280            self.push_system("Interrupting… (press Ctrl+C again to exit)");
281            return Some(UserAction::Interrupt);
282        }
283
284        // Step 3: idle and empty — explicitly no-op (do **not** quit).
285        None
286    }
287
288    /// Goal-147: dispatch Ctrl+C with double-press semantics.
289    ///
290    /// Order of resolution:
291    ///   1. Two presses inside [`double_press_window`] → real exit.
292    ///   2. Modal active → pop the topmost modal (single-press path).
293    ///   3. Buffer non-empty → clear it.
294    ///   4. Turn running → `UserAction::Interrupt` + System block.
295    ///   5. Idle and empty → arm the "press again to exit" hint.
296    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            // Second press inside the window → exit.
308            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        // Step 2: pop a modal.
316        if !self.modals.is_empty() {
317            self.modals.pop();
318            return None;
319        }
320
321        // Step 3: clear buffer.
322        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        // Step 4: interrupt the running turn.
331        if self.turn.running {
332            self.push_system("Interrupting… (press Ctrl+C again to exit)");
333            return Some(UserAction::Interrupt);
334        }
335
336        // Step 5: idle, empty → arm the second press.
337        self.push_system("Press Ctrl+C again to exit");
338        None
339    }
340
341    /// History walk on Up should fire when (a) we are already
342    /// walking (history_idx is Some) — so consecutive ↑ keep
343    /// stepping back — or (b) the buffer is empty (entry point per
344    /// goal §5).
345    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        // Auto-detect mode from the first character when the buffer
358        // is empty. The prefix character itself is consumed (used as
359        // the mode marker, not stored).
360        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        // Goal-158: `@` anywhere in Prompt mode triggers AtFile
378        // completion. The `@` itself IS inserted into the buffer so
379        // the user can see their typing; the query starts empty.
380        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    /// Dispatch the current buffer based on the active mode. Returns
389    /// the [`UserAction`] (if any) the caller must forward to the
390    /// backend worker. Always resets the prompt to a clean state.
391    fn submit_prompt(&mut self) -> Option<UserAction> {
392        if self.prompt.buffer.is_empty() {
393            // Don't submit empty prompts. Stay where we are — but if
394            // the user is in a non-Prompt mode with nothing typed, do
395            // nothing rather than spamming a no-op System block.
396            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            // AtFile mode is handled before submit_prompt is reached
426            // (handle_atfile_key intercepts Enter). Treat as Prompt if
427            // somehow reached here.
428            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            // HistorySearch intercepts Enter before submit_prompt.
437            // Treat defensively as Prompt.
438            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    /// Parse `body` (without the leading `/`) as `name + args`, look
454    /// it up in [`App::commands`], and run the handler. Returns an
455    /// optional [`UserAction`] for the dispatcher.
456    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        // Clone the registry to avoid borrowing self while invoking
464        // the handler (which takes &mut self).
465        let registry = self.commands.clone();
466
467        // Goal-169: check built-in commands first, then skill commands.
468        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                    // The dispatcher only carries one UserAction back to
481                    // the caller; queue the rest into App for later. In
482                    // practice every async command returns 0 or 1 actions
483                    // today.
484                    actions.into_iter().next()
485                }
486            };
487        }
488
489        // Goal-169: skill command fallback.
490        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    // ── Goal-146: command-menu ────────────────────────────────────────
510
511    /// Handle a key in command-completion-menu context. Returns
512    /// `Some(action)` (with `action` itself optional) if the key was
513    /// consumed; the outer `None` means "fall through to the regular
514    /// chat key path".
515    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 a menu item is selected, execute it; otherwise
554                // fall through to the regular submit path so the
555                // user's literal buffer is dispatched.
556                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    // ── Goal-158: @file completion helpers ───────────────────────────
573
574    /// Switch to AtFile mode and populate the initial suggestion list.
575    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    /// Recompute [`App::atfile_suggestions`] from [`App::atfile_query`].
583    fn refresh_atfile_suggestions(&mut self) {
584        self.atfile_suggestions = glob_workspace_files(&self.atfile_query);
585        // Clamp selection so it doesn't point past the new list.
586        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    /// Insert the selected (or first) suggestion into the buffer,
598    /// replacing the `@<query>` tail that was typed.
599    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        // Replace the `@<query>` suffix in the buffer with `@<chosen>`.
606        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    /// Return to Prompt mode and clear completion state.
619    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    /// Handle a key when [`InputMode::AtFile`] is active.
627    pub fn handle_atfile_key(&mut self, key: KeyEvent) -> Option<UserAction> {
628        match key.code {
629            KeyCode::Esc => {
630                // Cancel: exit AtFile mode, keep `@<query>` in buffer.
631                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                    // Delete the `@` from the buffer and exit AtFile mode.
662                    self.exit_atfile_mode();
663                    self.prompt.backspace(); // removes `@`
664                } else {
665                    // Delete last char from query and buffer.
666                    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    // ── Goal-160: Ctrl+R history search ───────────────────────────────
690
691    /// Enter HistorySearch mode, clearing the search query and
692    /// pre-populating matches with all history entries (most recent first).
693    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    /// Refresh [`App::hsearch_matches`] from [`App::hsearch_query`].
701    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    /// Fill the prompt buffer with the currently selected history entry
709    /// and return to Prompt mode.
710    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    /// Return to Prompt mode and clear search state.
721    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    /// Handle a key when [`InputMode::HistorySearch`] is active.
729    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    // ── Goal-161: permission modal ────────────────────────────────────
779
780    /// Handle a key while a permission modal is active.
781    /// - `y` / `Y` / `Enter` → allow once
782    /// - `n` / `N` / `Esc`   → deny
783    /// - `a` / `A`           → allow + add tool to auto-allow list
784    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    // ── Modal dispatch ────────────────────────────────────────────────
801
802    /// Handle a key event when at least one modal is on the stack.
803    /// Returns `Some(action)` if the modal layer wants to forward a
804    /// [`UserAction`] to the backend (currently only the PlanReview
805    /// modal does this). The outer key dispatcher should not also
806    /// process this key against the chat layer.
807    pub fn handle_modal_key_action(&mut self, key: KeyEvent) -> Option<UserAction> {
808        use crate::tui::ui::modal::Modal;
809
810        // Goal-147: PlanReview modal owns y / n / e / Enter / Esc and
811        // *bypasses* the generic confirm logic.
812        if let Some(Modal::PlanReview { .. }) = self.modals.last() {
813            return self.handle_plan_review_key(key);
814        }
815
816        // Goal-171: ResumePicker owns ↑/↓/Enter/Esc and may return a UserAction.
817        if let Some(Modal::ResumePicker { .. }) = self.modals.last() {
818            return self.handle_resume_picker_key(key);
819        }
820
821        // Goal-173: McpServers owns ↑/↓/Esc.
822        if let Some(Modal::McpServers { .. }) = self.modals.last() {
823            return self.handle_mcp_servers_key(key);
824        }
825
826        // Generic modal dispatch (Goal 146).
827        self.handle_modal_key(key);
828        None
829    }
830
831    /// Goal-147: dispatch a key against an active `Modal::PlanReview`.
832    ///
833    /// * `y` / `Enter` → emit `UserAction::ConfirmPlan`. The modal is
834    ///   **not** popped here — we wait for the runtime's
835    ///   `PlanConfirmed` event so the visible state matches the
836    ///   server-side decision.
837    /// * `n` / `Esc` → pop the modal immediately and emit
838    ///   `UserAction::RejectPlan("user rejected")`. Goal §8 forbids
839    ///   collecting a free-form reason here.
840    /// * `e` → copy the plan text into the prompt buffer (Prompt
841    ///   mode), close the modal, and let the user edit/resend
842    ///   normally.
843    /// * Any other key is consumed but ignored, keeping plan-mode
844    ///   focus.
845    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                // Optimistic close: pop the modal immediately so the user
851                // sees the dismissal without waiting for the PlanConfirmed
852                // event to round-trip from the runtime.
853                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    /// Goal-202: dispatch a key when `plan_mode_request_pending` is set.
872    ///
873    /// * `y` / `Enter` → approve — optimistically clears the pending flag
874    ///   and emits `UserAction::ApprovePlanMode`.
875    /// * `n` / `Esc` → reject — clears the flag and emits
876    ///   `UserAction::RejectPlanMode("user skipped")`.
877    /// * Any other key is consumed (request focus kept).
878    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    /// Goal-171: dispatch a key against an active `Modal::ResumePicker`.
893    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    /// Goal-173: dispatch a key against an active `Modal::McpServers`.
942    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    /// Handle a key event when at least one modal is on the stack.
980    /// Returns `true` if the key was consumed by the modal layer
981    /// (so the caller should skip the chat key path).
982    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                    // Enter on non-confirm modals just dismisses.
1018                    self.modals.pop();
1019                }
1020            }
1021            KeyCode::Up | KeyCode::PageUp => {
1022                let step: u16 = if key.code == KeyCode::PageUp { 10 } else { 1 };
1023                // Journal: move selection up and auto-scroll to keep it visible.
1024                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                    // Generic text scroll (Help, ToolList, PlanReview, …).
1035                    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                // Journal: move selection down and auto-scroll to keep it visible.
1041                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                    // Generic text scroll (Help, ToolList, PlanReview, …).
1052                    self.modal_scroll = self.modal_scroll.saturating_add(step);
1053                }
1054            }
1055            _ => {}
1056        }
1057        true
1058    }
1059
1060    /// Approximate number of visible content rows inside the expanded modal
1061    /// (40-row viewport × 90% height − 2 border − 3 header lines).
1062    const MODAL_LIST_VISIBLE: u16 = 28;
1063
1064    /// Auto-adjust `modal_scroll` so that the item at position `selected`
1065    /// (0-based) is always within the visible window of a list modal.
1066    /// Accounts for the 2-line header (title + blank) above the list.
1067    fn modal_scroll_follow_selection(&mut self, selected: usize) {
1068        let row = selected as u16 + 2; // +2 for header lines
1069        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// ──────────────────────────────────────────────────────────────────────
1078// Tests
1079// ──────────────────────────────────────────────────────────────────────
1080
1081#[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    // ── Ctrl+E ─────────────────────────────────────────────────────
1103
1104    #[test]
1105    fn ctrl_e_toggles_expanded_on_last_tool_result() {
1106        let mut app = App::new();
1107        app.screen = AppScreen::Chat;
1108        // No prior ToolCall — the ToolResult handler falls back to
1109        // synthesising a ToolCall block with Some(result). The test
1110        // still drives the toggle path.
1111        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    // ── chat key handling ──────────────────────────────────────────
1136
1137    #[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    /// Plain ↑ never scrolls — even with empty buffer it walks
1190    /// history once any has been recorded; with no history it's a
1191    /// no-op. Transcript scrolling is reserved for Shift+↑/↓ and
1192    /// PgUp/PgDn (Goal 150 fix: history was always shadowing
1193    /// scroll, leaving the transcript stuck at bottom).
1194    #[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    /// PgUp/PgDn now work regardless of buffer state.
1253    #[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    /// Goal 150 follow-up: terminal-independent scroll fallbacks
1263    /// were once provided by Ctrl+B / Ctrl+F. After switching to
1264    /// emacs-style cursor motion (the macOS Terminal crowd asked
1265    /// for B/F as left/right arrows, and modern terminals all
1266    /// deliver PageUp/PageDown reliably), the transcript scroll
1267    /// path now lives on `PageUp` / `PageDown` / `Shift+↑↓` /
1268    /// mouse wheel — covered by the tests in `keymap.rs` under
1269    /// `dispatch_ctrl_b_moves_cursor_left` and friends. The
1270    /// two tests that used to live here asserted the old scroll
1271    /// behaviour and are intentionally removed.
1272
1273    // ── Plan Mode (Goal 147) ───────────────────────────────────────
1274
1275    #[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        // Fix-E: the modal is now optimistically closed on 'y'.
1288        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    /// Goal §5: Esc closes the topmost modal rather than quitting.
1327    #[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    /// Goal §5: with no modal but a non-empty buffer, Esc clears the
1339    /// buffer and does not quit, even on a single press.
1340    #[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    /// Goal §5: Esc does **not** quit even on a second press inside
1351    /// the double-press window.
1352    #[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    /// Goal §5: Ctrl+C during a running turn dispatches an Interrupt
1362    /// action and writes a System block.
1363    #[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    /// Goal §5: Ctrl+C while idle pushes a "press again to exit"
1376    /// hint, then a second press inside the window quits.
1377    #[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    /// Goal §5: Ctrl+C×2 inside the window quits regardless of the
1390    /// soft action the first press kicked off.
1391    #[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        // Second press almost-instantly: must quit.
1398        let _ = app.handle_key(ctrl('c'));
1399        assert!(app.should_quit);
1400    }
1401
1402    /// Goal §5: a Ctrl+C press outside the double-press window
1403    /// resets the counter.
1404    #[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        // Backdate last_ctrl_c_at so the next press is "outside".
1410        app.double_press.last_ctrl_c_at = Some(Instant::now() - Duration::from_secs(60));
1411        let action = app.handle_key(ctrl('c'));
1412        // First press fresh round: idle + empty → arms the warning.
1413        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// ──────────────────────────────────────────────────────────────────────
1421// PromptInput tests (Goal 145)
1422// ──────────────────────────────────────────────────────────────────────
1423
1424#[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    // ── prompt_input::shift_tab_cycles_modes ────────────────────────
1454
1455    #[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    // ── prompt_input::leading_<x>_enters_<mode>_when_buffer_empty ──
1468
1469    #[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        // The `!` is consumed as the mode marker, not stored.
1475        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    // ── prompt_input::backspace_on_empty_exits_to_prompt_mode ───────
1504
1505    #[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    // ── prompt_input::cursor_left_right_moves_within_buffer ─────────
1515
1516    #[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        // Each Chinese char is 3 bytes in UTF-8.
1538        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    // ── prompt_input::shift_enter_inserts_newline_at_cursor ─────────
1557
1558    #[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    // ── prompt_input::history_up_down_navigates_records ─────────────
1577
1578    #[test]
1579    fn history_up_down_navigates_records() {
1580        let mut app = fresh_app();
1581        // Submit two messages.
1582        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        // Past newest → restored draft (empty here).
1596        assert!(app.prompt.buffer.is_empty());
1597    }
1598
1599    // ── prompt_input::history_up_saves_draft_and_restores_on_overflow ─
1600
1601    #[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        // Walk history: only triggers when buffer is empty.
1607        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        // Submit a bash command.
1617        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        // Walk back: should restore Bash mode.
1624        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        // The earliest entries should have been dropped.
1638        assert!(!app.prompt.history.iter().any(|h| h == "msg0"));
1639    }
1640
1641    // ── prompt_input::submit_in_bash_mode_dispatches_run_shell ──────
1642
1643    #[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    // ── prompt_input::submit_in_note_mode_appends_system_block ──────
1658
1659    #[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        // No backend action: notes are local-only.
1669        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        // Goal-146 replaces the old placeholder System block with the
1680        // actual command dispatcher. /help opens the Help modal.
1681        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        // /help pushed a Help modal onto the stack.
1689        assert_eq!(app.modals.last(), Some(&crate::tui::ui::modal::Modal::Help));
1690        // Buffer was reset.
1691        assert!(app.prompt.buffer.is_empty());
1692        assert_eq!(app.prompt.mode, InputMode::Prompt);
1693    }
1694
1695    // ── prompt_input::submit_clears_buffer_and_resets_mode ──────────
1696
1697    #[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    // ── home / end on multi-line ────────────────────────────────────
1710
1711    #[test]
1712    fn home_end_target_current_line_only() {
1713        let mut app = fresh_app();
1714        app.set_input("ab\ncd");
1715        // cursor is at end (5).
1716        app.prompt.cursor = 4; // between c and d
1717        let _ = app.handle_key(k(KeyCode::Home));
1718        assert_eq!(app.prompt.cursor, 3); // start of "cd"
1719        let _ = app.handle_key(k(KeyCode::End));
1720        assert_eq!(app.prompt.cursor, 5); // end of buffer
1721    }
1722
1723    // ── ctrl+e disambiguation (goal §10) ────────────────────────────
1724
1725    #[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    // ── exhaustively cover history's empty-on-down case ─────────────
1764
1765    #[test]
1766    fn history_down_with_no_walk_in_progress_is_noop() {
1767        let mut app = fresh_app();
1768        // Down on empty, no history → falls through to scroll path.
1769        let _ = app.handle_key(k(KeyCode::Down));
1770        assert!(app.prompt.history_idx.is_none());
1771    }
1772
1773    // ── strip_history_prefix utility ────────────────────────────────
1774
1775    #[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// ── Goal-158: @file autocomplete tests ───────────────────────────────────────
1786
1787#[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        // We can only test that the function returns a Vec and doesn't panic;
1826        // actual path results are environment-dependent.
1827        let results = glob_workspace_files("Cargo");
1828        // Should be ≤ MAX_ATFILE_SUGGESTIONS
1829        assert!(results.len() <= MAX_ATFILE_SUGGESTIONS);
1830        // All returned paths should contain "cargo" (case-insensitive)
1831        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        // Type some text, then '@'
1846        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        // Backspace with empty query should exit mode and remove '@'
1853        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        // Manually inject a suggestion so the test is deterministic.
1865        app.atfile_suggestions = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
1866        app.atfile_selected = Some(0);
1867
1868        // Press Enter to commit.
1869        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        // Type a query.
1888        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        // Press Esc — mode should exit but buffer kept.
1896        app.handle_atfile_key(k(KeyCode::Esc));
1897        assert_eq!(app.prompt.mode, InputMode::Prompt);
1898        assert_eq!(app.prompt.buffer, buf_before);
1899        // Suggestion list is cleared.
1900        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        // Down selects first item.
1911        app.handle_atfile_key(k(KeyCode::Down));
1912        assert_eq!(app.atfile_selected, Some(0));
1913
1914        // Down again — second.
1915        app.handle_atfile_key(k(KeyCode::Down));
1916        assert_eq!(app.atfile_selected, Some(1));
1917
1918        // Up — back to first.
1919        app.handle_atfile_key(k(KeyCode::Up));
1920        assert_eq!(app.atfile_selected, Some(0));
1921
1922        // Up again — deselects (None).
1923        app.handle_atfile_key(k(KeyCode::Up));
1924        assert_eq!(app.atfile_selected, None);
1925    }
1926}
1927
1928// ── Goal-160: Ctrl+R history search tests ────────────────────────────────────
1929
1930#[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    // ── search_history unit tests ──────────────────────────────────────
1954
1955    #[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        // Most recent first: indices 2,1,0.
1960        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        // Entries 0 and 2 start with "foo"; entry 1 is a substring match.
1972        // Prefix matches come first; within prefix group, reversed = 2 then 0.
1973        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    // ── App integration tests ──────────────────────────────────────────
1993
1994    #[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        // All entries pre-loaded.
2001        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        // Should stay in Bash mode, not HistorySearch.
2010        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        // With empty query, most recent first: index 1 ("cargo test") selected.
2019        assert_eq!(app.hsearch_selected, 0);
2020        // Press Enter → fill buffer with the selected entry.
2021        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        // Buffer should be unchanged.
2034        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        // Backspace on empty query exits HistorySearch.
2044        app.handle_history_search_key(k(KeyCode::Backspace));
2045        assert_eq!(app.prompt.mode, InputMode::Prompt);
2046    }
2047}
2048
2049// ── Goal-161: Permission Modal tests ─────────────────────────────────────────
2050
2051#[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        // Should auto-allow without storing to pending_permission.
2135        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        // When pending_permission is set, handle_key routes to permission handler.
2143        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        // N key via handle_key should route to permission handler.
2157        app.handle_key(k(KeyCode::Char('n')));
2158        assert!(app.pending_permission.is_none());
2159    }
2160
2161    // ── Goal-202: plan-mode pre-confirmation ───────────────────────────
2162
2163    #[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}