Skip to main content

pi/modes/interactive/
input.rs

1//! App-level input dispatch: maps [`UiEvent`]s into [`ViewAction`]s.
2//!
3//! This module is pure (no I/O, no async, no terminal access). The runtime
4//! loop calls [`InputMapper::map`] with a [`ViewState`] snapshot plus the
5//! [`EventResult`](pi_tui::component::EventResult) the focused component
6//! returned for the same event. If the focused component consumed the event,
7//! the mapper defers entirely; otherwise it checks the small closed set of
8//! application keybindings (Ctrl+C / Ctrl+D / Ctrl+Z / Esc / Shift+Tab /
9//! Ctrl+P / Ctrl+L / Ctrl+O / Ctrl+T / Ctrl+G / Ctrl+X / Alt+Enter / Alt+Up)
10//! and emits the matching semantic action.
11//!
12//! Double-tap timing for "press Ctrl+C twice within 500ms to exit" and
13//! "press Esc twice within 500ms to open `/tree` or `/fork`" lives in
14//! [`InputState`]; the runtime owns one instance for the lifetime of the
15//! session and resets it whenever focus moves to or from a selector.
16//!
17//! Field and constant names mirror `.references/pi/packages/coding-agent/
18//! src/modes/interactive/interactive-mode.ts` (key handler block) and
19//! `core/keybindings.ts` defaults.
20
21use std::time::{Duration, Instant};
22
23use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
24use pi_tui::component::UiEvent;
25
26use super::state::{OverlayKind, StatusKind, ViewAction, ViewState};
27
28/// Default double-tap window for "exit on second tap" semantics.
29///
30/// Mirrors the TS literal `500` used in `handleCtrlC` and the double-Esc
31/// handler (`interactive-mode.ts:3464` and `:2541`).
32pub const DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(500);
33
34/// What double-Esc on an empty editor should do.
35///
36/// Ports `getDoubleEscapeAction()` from settings (`"none" | "tree" | "fork"`).
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum DoubleEscapeAction {
39    /// No double-Esc behaviour (single Esc still clears the editor).
40    #[default]
41    None,
42    /// Open the branch tree (`/tree`) selector.
43    Tree,
44    /// Open the user-message fork (`/fork`) selector.
45    Fork,
46}
47
48/// Mutable state carried across input events for double-tap detection.
49///
50/// Owned by the runtime; reset whenever focus moves to a selector / overlay
51/// (a fresh tap window starts after the selector closes).
52#[derive(Clone, Copy, Debug)]
53pub struct InputState {
54    last_sigint: Option<Instant>,
55    last_escape: Option<Instant>,
56    double_escape_action: DoubleEscapeAction,
57    sigint_exit_window: Duration,
58    escape_double_window: Duration,
59}
60
61impl Default for InputState {
62    fn default() -> Self {
63        Self::new(DoubleEscapeAction::default())
64    }
65}
66
67impl InputState {
68    /// Build a fresh state with the configured double-Esc action.
69    #[must_use]
70    pub fn new(double_escape_action: DoubleEscapeAction) -> Self {
71        Self {
72            last_sigint: None,
73            last_escape: None,
74            double_escape_action,
75            sigint_exit_window: DOUBLE_TAP_WINDOW,
76            escape_double_window: DOUBLE_TAP_WINDOW,
77        }
78    }
79
80    /// Update the configured double-Esc action (e.g. after a settings reload).
81    pub fn set_double_escape_action(&mut self, action: DoubleEscapeAction) {
82        self.double_escape_action = action;
83    }
84
85    /// Override the Ctrl+C double-tap window (tests / settings override).
86    pub fn set_sigint_exit_window(&mut self, window: Duration) {
87        self.sigint_exit_window = window;
88    }
89
90    /// Override the Esc double-tap window (tests / settings override).
91    pub fn set_escape_double_window(&mut self, window: Duration) {
92        self.escape_double_window = window;
93    }
94
95    /// Drop both remembered taps (called after focus changes / overlays open).
96    pub fn reset_taps(&mut self) {
97        self.last_sigint = None;
98        self.last_escape = None;
99    }
100
101    /// Last Ctrl+C timestamp (for tests).
102    #[must_use]
103    pub fn last_sigint(&self) -> Option<Instant> {
104        self.last_sigint
105    }
106
107    /// Last Esc timestamp (for tests).
108    #[must_use]
109    pub fn last_escape(&self) -> Option<Instant> {
110        self.last_escape
111    }
112
113    /// Set the last Ctrl+C timestamp (test-only seam).
114    #[cfg(test)]
115    pub(crate) fn set_last_sigint_for_test(&mut self, instant: Option<Instant>) {
116        self.last_sigint = instant;
117    }
118}
119
120/// Pure input mapper.
121///
122/// Stateless beyond the borrowed [`InputState`]; one instance lives for the
123/// runtime's lifetime. Methods never touch the terminal, the session, or any
124/// I/O.
125#[derive(Debug, Default)]
126pub struct InputMapper;
127
128impl InputMapper {
129    /// Construct a fresh mapper.
130    #[must_use]
131    pub fn new() -> Self {
132        Self
133    }
134
135    /// Translate one [`UiEvent`] into zero or more ordered [`ViewAction`]s.
136    ///
137    /// `editor_consumed` is `true` when the focused editor/component already
138    /// returned `Consumed` or `Render` for this event; in that case the mapper
139    /// only emits actions for non-key events (`Resize`, `Paste`) that the
140    /// component cannot logically claim.
141    ///
142    /// `editor_text` is the live editor buffer at the moment the event was
143    /// received — the snapshot in `view.editor.text` may lag by one paint.
144    /// Callers should pass the editor's current `get_text()` so submit /
145    /// bash-detection see the same string the user typed.
146    #[must_use]
147    pub fn map(
148        &self,
149        event: &UiEvent,
150        view: &ViewState,
151        editor_text: &str,
152        state: &mut InputState,
153        editor_consumed: bool,
154    ) -> Vec<ViewAction> {
155        let mut out = Vec::new();
156        match event {
157            UiEvent::Resize { width, height } => {
158                out.push(ViewAction::Resize {
159                    width: *width,
160                    height: *height,
161                });
162            }
163            UiEvent::Paste(text) => {
164                // Bracketed paste is delivered to the editor first. If it was
165                // not consumed (no editor focused), surface as a Paste action
166                // so the runtime can splice it into the live buffer.
167                if !editor_consumed && !text.is_empty() {
168                    out.push(ViewAction::Paste { text: text.clone() });
169                }
170            }
171            UiEvent::FocusGained | UiEvent::FocusLost => {
172                // No app-level action; the runtime uses these as a heuristic
173                // to re-probe terminal light/dark on FocusGained.
174            }
175            UiEvent::Key(key) => {
176                if !editor_consumed {
177                    Self::map_key(*key, view, editor_text, state, &mut out);
178                }
179            }
180        }
181        out
182    }
183
184    fn map_key(
185        key: KeyEvent,
186        view: &ViewState,
187        editor_text: &str,
188        state: &mut InputState,
189        out: &mut Vec<ViewAction>,
190    ) {
191        let mods = key.modifiers;
192        match (mods, key.code) {
193            // Ctrl+C: double-tap within window → Exit; otherwise Interrupt.
194            // First tap also clears the editor (mirrors `handleCtrlC`).
195            (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
196                let now = Instant::now();
197                let double = state
198                    .last_sigint
199                    .is_some_and(|t| now.duration_since(t) < state.sigint_exit_window);
200                if double {
201                    state.last_sigint = None;
202                    out.push(ViewAction::ClearEditor);
203                    out.push(ViewAction::Exit);
204                } else {
205                    state.last_sigint = Some(now);
206                    out.push(ViewAction::ClearEditor);
207                    out.push(ViewAction::Interrupt);
208                }
209            }
210            // Ctrl+D: exit only when the editor is empty AND no overlay is up
211            // (parity with `handleCtrlD`: "Only called when editor is empty").
212            (KeyModifiers::CONTROL, KeyCode::Char('d')) => {
213                if editor_text.trim().is_empty() && view.overlay.is_none() {
214                    out.push(ViewAction::Exit);
215                }
216            }
217            // Ctrl+Z: suspend (Windows no-op is handled by the runtime).
218            (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
219                out.push(ViewAction::Suspend);
220            }
221            // Shift+Tab: cycle thinking level forward.
222            (KeyModifiers::SHIFT, KeyCode::BackTab | KeyCode::Tab) => {
223                out.push(ViewAction::CycleThinking { forward: true });
224            }
225            // Ctrl+P: cycle model forward.
226            // Ctrl+P (lowercase, no shift): cycle model forward.
227            // The SHIFT bit is checked exactly so Ctrl+Shift+P (which may
228            // arrive as lowercase 'p' with SHIFT on some platforms) falls
229            // through to the backward arm below.
230            (mods, KeyCode::Char('p')) if mods == KeyModifiers::CONTROL => {
231                out.push(ViewAction::CycleModel { forward: true });
232            }
233            // Ctrl+Shift+P (any case, with SHIFT bit): cycle backward.
234            (mods, KeyCode::Char('P'))
235                if mods.contains(KeyModifiers::CONTROL) && mods.contains(KeyModifiers::SHIFT) =>
236            {
237                out.push(ViewAction::CycleModel { forward: false });
238            }
239            (mods, KeyCode::Char('p'))
240                if mods.contains(KeyModifiers::CONTROL) && mods.contains(KeyModifiers::SHIFT) =>
241            {
242                out.push(ViewAction::CycleModel { forward: false });
243            }
244            // Ctrl+L: open the model selector.
245            (KeyModifiers::CONTROL, KeyCode::Char('l')) => {
246                out.push(ViewAction::OpenModelSelector);
247            }
248            // Ctrl+O: toggle tool expansion.
249            (KeyModifiers::CONTROL, KeyCode::Char('o')) => {
250                out.push(ViewAction::ToggleToolExpand);
251            }
252            // Ctrl+T: toggle thinking block visibility.
253            (KeyModifiers::CONTROL, KeyCode::Char('t')) => {
254                out.push(ViewAction::ToggleThinking);
255            }
256            // Ctrl+G: open external editor.
257            (KeyModifiers::CONTROL, KeyCode::Char('g')) => {
258                out.push(ViewAction::ExternalEditor);
259            }
260            // Ctrl+X: copy last assistant message to clipboard.
261            (KeyModifiers::CONTROL, KeyCode::Char('x')) => {
262                out.push(ViewAction::CopyLastAssistant);
263            }
264            // Alt+Enter: queue a follow-up while streaming, otherwise submit.
265            (KeyModifiers::ALT, KeyCode::Enter) => {
266                let text = editor_text.trim().to_owned();
267                if text.is_empty() {
268                    return;
269                }
270                if view.streaming {
271                    out.push(ViewAction::QueueFollowUp { text });
272                    out.push(ViewAction::ClearEditor);
273                } else {
274                    out.push(ViewAction::Submit { text });
275                    out.push(ViewAction::ClearEditor);
276                }
277            }
278            // Alt+Up: restore the last queued follow-up to the editor.
279            (KeyModifiers::ALT, KeyCode::Up) => {
280                out.push(ViewAction::DequeueFollowUp);
281            }
282            // Ctrl+V / Alt+V: paste image from clipboard (runtime owns the
283            // clipboard call; emit a Paste with empty payload so the runtime
284            // knows it was a clipboard-image request).
285            (mods, KeyCode::Char('v'))
286                if mods == KeyModifiers::CONTROL || mods == KeyModifiers::ALT =>
287            {
288                out.push(ViewAction::Paste {
289                    text: String::new(),
290                });
291            }
292            // Ctrl+R: open reload (slash-command equivalent of /reload).
293            (KeyModifiers::CONTROL, KeyCode::Char('r')) => {
294                out.push(ViewAction::Reload);
295            }
296            // Ctrl+B: open the session picker (/resume).
297            (KeyModifiers::CONTROL, KeyCode::Char('b')) => {
298                out.push(ViewAction::OpenSessionPicker);
299            }
300            // Ctrl+F: open the tree (/tree) selector.
301            (KeyModifiers::CONTROL, KeyCode::Char('f')) => {
302                out.push(ViewAction::OpenTreeSelector);
303            }
304            // Ctrl+N: new session.
305            (KeyModifiers::CONTROL, KeyCode::Char('n')) => {
306                out.push(ViewAction::NewSession);
307            }
308            // Esc: dispatch contextually (overlay → dismiss; streaming →
309            // interrupt; bash → interrupt; double-tap on empty → tree/fork;
310            // otherwise clear editor).
311            (_, KeyCode::Esc) => {
312                Self::map_escape(view, editor_text, state, out);
313            }
314            _ => {}
315        }
316    }
317
318    fn map_escape(
319        view: &ViewState,
320        editor_text: &str,
321        state: &mut InputState,
322        out: &mut Vec<ViewAction>,
323    ) {
324        // 1. Any active overlay dismisses first.
325        if view.overlay.is_some() {
326            out.push(ViewAction::DismissOverlay);
327            state.reset_taps();
328            return;
329        }
330        // 2. Streaming agent → interrupt (also restores queued messages,
331        //    which the runtime does after the abort resolves).
332        if view.streaming {
333            out.push(ViewAction::Interrupt);
334            state.reset_taps();
335            return;
336        }
337        // 3. Compaction / retry / branch-summary / working → Esc cancels.
338        if let Some(status) = &view.status {
339            let cancel_kind = matches!(
340                status.kind,
341                StatusKind::Compaction
342                    | StatusKind::Retry
343                    | StatusKind::BranchSummary
344                    | StatusKind::Working
345            );
346            if cancel_kind {
347                out.push(ViewAction::Interrupt);
348                state.reset_taps();
349                return;
350            }
351        }
352        // 4. Double-Esc on empty editor → open tree/fork per setting.
353        if editor_text.trim().is_empty()
354            && !matches!(state.double_escape_action, DoubleEscapeAction::None)
355        {
356            let now = Instant::now();
357            let double = state
358                .last_escape
359                .is_some_and(|t| now.duration_since(t) < state.escape_double_window);
360            if double {
361                state.last_escape = None;
362                match state.double_escape_action {
363                    DoubleEscapeAction::Tree => out.push(ViewAction::OpenTreeSelector),
364                    DoubleEscapeAction::Fork => out.push(ViewAction::OpenForkSelector),
365                    DoubleEscapeAction::None => {}
366                }
367            } else {
368                state.last_escape = Some(now);
369            }
370            return;
371        }
372        // 5. Single Esc with non-empty editor → clear.
373        if !editor_text.trim().is_empty() {
374            out.push(ViewAction::ClearEditor);
375        }
376        // Otherwise: no-op.
377    }
378}
379
380/// Helper for tests / settings reload: build a state from the wire string.
381///
382/// Mirrors `getDoubleEscapeAction` default mapping (`"none" | "tree" | "fork"`).
383#[must_use]
384pub fn double_escape_action_from_str(s: &str) -> DoubleEscapeAction {
385    match s {
386        "tree" => DoubleEscapeAction::Tree,
387        "fork" => DoubleEscapeAction::Fork,
388        _ => DoubleEscapeAction::None,
389    }
390}
391
392/// Re-exported so the runtime can construct the default overlay kind list.
393#[must_use]
394pub fn dismissable_overlay_kinds() -> &'static [OverlayKind] {
395    &[
396        OverlayKind::ShortcutHelp,
397        OverlayKind::Changelog,
398        OverlayKind::FirstTimeSetup,
399        OverlayKind::Login,
400        OverlayKind::Extension,
401    ]
402}
403
404#[cfg(test)]
405mod tests {
406    use std::time::Duration;
407
408    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
409    use pi_ai::AssistantMessage;
410    use pi_tui::component::UiEvent;
411
412    use super::*;
413    use crate::modes::interactive::messages::MessageView;
414    use crate::modes::interactive::state::{
415        EditorBorder, EditorView, FocusArea, Overlay, OverlayKind, SessionStatus, StatusKind,
416        ViewState,
417    };
418
419    fn key(code: KeyCode, mods: KeyModifiers) -> UiEvent {
420        UiEvent::Key(KeyEvent::new(code, mods))
421    }
422
423    fn ctrl(c: char) -> UiEvent {
424        key(KeyCode::Char(c), KeyModifiers::CONTROL)
425    }
426
427    fn alt(code: KeyCode) -> UiEvent {
428        key(code, KeyModifiers::ALT)
429    }
430
431    fn ctrl_shift(c: char) -> UiEvent {
432        key(
433            KeyCode::Char(c),
434            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
435        )
436    }
437
438    fn shift(code: KeyCode) -> UiEvent {
439        key(code, KeyModifiers::SHIFT)
440    }
441
442    fn plain(code: KeyCode) -> UiEvent {
443        key(code, KeyModifiers::NONE)
444    }
445
446    fn empty_view() -> ViewState {
447        ViewState::empty()
448    }
449
450    fn view_with_editor(text: &str) -> ViewState {
451        let mut v = empty_view();
452        v.editor = EditorView {
453            text: text.to_owned(),
454            cursor: text.chars().count(),
455            placeholder: String::new(),
456            border: EditorBorder::Muted,
457            paste_marker: None,
458        };
459        v
460    }
461
462    fn view_streaming() -> ViewState {
463        let mut v = empty_view();
464        v.streaming = true;
465        v.status = Some(SessionStatus {
466            kind: StatusKind::Working,
467            frame: 0,
468            message: "Working".to_owned(),
469        });
470        let assistant = AssistantMessage::new("anthropic", "test", "test", 0);
471        v.messages.push(MessageView::streaming_assistant(assistant));
472        v
473    }
474
475    fn view_with_overlay(kind: OverlayKind) -> ViewState {
476        let mut v = empty_view();
477        v.overlay = Some(Overlay {
478            kind,
479            lines: vec!["overlay".to_owned()],
480            height: 3,
481        });
482        v
483    }
484
485    fn view_with_status(kind: StatusKind) -> ViewState {
486        let mut v = empty_view();
487        v.status = Some(SessionStatus {
488            kind,
489            frame: 0,
490            message: "x".to_owned(),
491        });
492        v
493    }
494
495    #[test]
496    fn resize_emits_resize_action() {
497        let mapper = InputMapper::new();
498        let mut state = InputState::default();
499        let view = empty_view();
500        let actions = mapper.map(
501            &UiEvent::Resize {
502                width: 100,
503                height: 30,
504            },
505            &view,
506            "",
507            &mut state,
508            false,
509        );
510        assert_eq!(actions.len(), 1);
511        assert_eq!(
512            actions[0],
513            ViewAction::Resize {
514                width: 100,
515                height: 30
516            }
517        );
518    }
519
520    #[test]
521    fn paste_when_editor_did_not_consume_emits_paste() {
522        let mapper = InputMapper::new();
523        let mut state = InputState::default();
524        let view = empty_view();
525        let actions = mapper.map(
526            &UiEvent::Paste("hello".to_owned()),
527            &view,
528            "",
529            &mut state,
530            false,
531        );
532        assert_eq!(
533            actions,
534            vec![ViewAction::Paste {
535                text: "hello".to_owned()
536            }]
537        );
538    }
539
540    #[test]
541    fn paste_consumed_by_editor_is_ignored_by_mapper() {
542        let mapper = InputMapper::new();
543        let mut state = InputState::default();
544        let view = empty_view();
545        let actions = mapper.map(
546            &UiEvent::Paste("hello".to_owned()),
547            &view,
548            "",
549            &mut state,
550            true,
551        );
552        assert!(actions.is_empty());
553    }
554
555    #[test]
556    fn empty_paste_is_ignored() {
557        let mapper = InputMapper::new();
558        let mut state = InputState::default();
559        let view = empty_view();
560        let actions = mapper.map(&UiEvent::Paste(String::new()), &view, "", &mut state, false);
561        assert!(actions.is_empty());
562    }
563
564    #[test]
565    fn ctrl_c_first_tap_clears_and_interrupts() {
566        let mapper = InputMapper::new();
567        let mut state = InputState::default();
568        let view = view_with_editor("draft");
569        let actions = mapper.map(&ctrl('c'), &view, "draft", &mut state, false);
570        assert_eq!(
571            actions,
572            vec![ViewAction::ClearEditor, ViewAction::Interrupt]
573        );
574        assert!(state.last_sigint.is_some());
575    }
576
577    #[test]
578    fn ctrl_c_double_tap_within_window_exits() {
579        let mapper = InputMapper::new();
580        let mut state = InputState::default();
581        let view = view_with_editor("x");
582        let _ = mapper.map(&ctrl('c'), &view, "x", &mut state, false);
583        let actions = mapper.map(&ctrl('c'), &view, "x", &mut state, false);
584        assert_eq!(actions, vec![ViewAction::ClearEditor, ViewAction::Exit]);
585        assert!(state.last_sigint.is_none());
586    }
587
588    #[test]
589    fn ctrl_c_double_tap_outside_window_just_interrupts() {
590        let mapper = InputMapper::new();
591        let mut state = InputState::default();
592        state.set_sigint_exit_window(Duration::from_nanos(1));
593        let view = view_with_editor("x");
594        let _ = mapper.map(&ctrl('c'), &view, "x", &mut state, false);
595        std::thread::sleep(Duration::from_millis(2));
596        let actions = mapper.map(&ctrl('c'), &view, "x", &mut state, false);
597        assert_eq!(
598            actions,
599            vec![ViewAction::ClearEditor, ViewAction::Interrupt]
600        );
601    }
602
603    #[test]
604    fn ctrl_d_only_exits_when_editor_empty() {
605        let mapper = InputMapper::new();
606        let mut state = InputState::default();
607        let empty = empty_view();
608        let actions = mapper.map(&ctrl('d'), &empty, "", &mut state, false);
609        assert_eq!(actions, vec![ViewAction::Exit]);
610
611        let view = view_with_editor("not empty");
612        let actions = mapper.map(&ctrl('d'), &view, "not empty", &mut state, false);
613        assert!(actions.is_empty());
614    }
615
616    #[test]
617    fn ctrl_d_does_not_exit_when_overlay_open() {
618        let mapper = InputMapper::new();
619        let mut state = InputState::default();
620        let view = view_with_overlay(OverlayKind::ShortcutHelp);
621        let actions = mapper.map(&ctrl('d'), &view, "", &mut state, false);
622        assert!(actions.is_empty());
623    }
624
625    #[test]
626    fn ctrl_z_emits_suspend() {
627        let mapper = InputMapper::new();
628        let mut state = InputState::default();
629        let view = empty_view();
630        let actions = mapper.map(&ctrl('z'), &view, "", &mut state, false);
631        assert_eq!(actions, vec![ViewAction::Suspend]);
632    }
633
634    #[test]
635    fn shift_tab_cycles_thinking() {
636        let mapper = InputMapper::new();
637        let mut state = InputState::default();
638        let view = empty_view();
639        let actions = mapper.map(&shift(KeyCode::Tab), &view, "", &mut state, false);
640        assert_eq!(actions, vec![ViewAction::CycleThinking { forward: true }]);
641    }
642
643    #[test]
644    fn ctrl_p_cycles_model_forward() {
645        let mapper = InputMapper::new();
646        let mut state = InputState::default();
647        let view = empty_view();
648        let actions = mapper.map(&ctrl('p'), &view, "", &mut state, false);
649        assert_eq!(actions, vec![ViewAction::CycleModel { forward: true }]);
650    }
651
652    #[test]
653    fn ctrl_shift_p_cycles_model_backward() {
654        let mapper = InputMapper::new();
655        let mut state = InputState::default();
656        let view = empty_view();
657        let actions = mapper.map(&ctrl_shift('P'), &view, "", &mut state, false);
658        assert_eq!(actions, vec![ViewAction::CycleModel { forward: false }]);
659    }
660
661    #[test]
662    fn app_keys_dispatch_table() {
663        let mapper = InputMapper::new();
664        let mut state = InputState::default();
665        let view = empty_view();
666
667        assert_eq!(
668            mapper.map(&ctrl('l'), &view, "", &mut state, false),
669            vec![ViewAction::OpenModelSelector]
670        );
671        assert_eq!(
672            mapper.map(&ctrl('o'), &view, "", &mut state, false),
673            vec![ViewAction::ToggleToolExpand]
674        );
675        assert_eq!(
676            mapper.map(&ctrl('t'), &view, "", &mut state, false),
677            vec![ViewAction::ToggleThinking]
678        );
679        assert_eq!(
680            mapper.map(&ctrl('g'), &view, "", &mut state, false),
681            vec![ViewAction::ExternalEditor]
682        );
683        assert_eq!(
684            mapper.map(&ctrl('x'), &view, "", &mut state, false),
685            vec![ViewAction::CopyLastAssistant]
686        );
687        assert_eq!(
688            mapper.map(&ctrl('r'), &view, "", &mut state, false),
689            vec![ViewAction::Reload]
690        );
691        assert_eq!(
692            mapper.map(&ctrl('b'), &view, "", &mut state, false),
693            vec![ViewAction::OpenSessionPicker]
694        );
695        assert_eq!(
696            mapper.map(&ctrl('f'), &view, "", &mut state, false),
697            vec![ViewAction::OpenTreeSelector]
698        );
699        assert_eq!(
700            mapper.map(&ctrl('n'), &view, "", &mut state, false),
701            vec![ViewAction::NewSession]
702        );
703    }
704
705    #[test]
706    fn alt_enter_submits_when_not_streaming() {
707        let mapper = InputMapper::new();
708        let mut state = InputState::default();
709        let view = view_with_editor("hello");
710        let actions = mapper.map(&alt(KeyCode::Enter), &view, "hello", &mut state, false);
711        assert_eq!(
712            actions,
713            vec![
714                ViewAction::Submit {
715                    text: "hello".to_owned()
716                },
717                ViewAction::ClearEditor,
718            ]
719        );
720    }
721
722    #[test]
723    fn alt_enter_queues_followup_when_streaming() {
724        let mapper = InputMapper::new();
725        let mut state = InputState::default();
726        let view = view_streaming();
727        let actions = mapper.map(&alt(KeyCode::Enter), &view, "more", &mut state, false);
728        assert_eq!(
729            actions,
730            vec![
731                ViewAction::QueueFollowUp {
732                    text: "more".to_owned()
733                },
734                ViewAction::ClearEditor,
735            ]
736        );
737    }
738
739    #[test]
740    fn alt_enter_empty_is_noop() {
741        let mapper = InputMapper::new();
742        let mut state = InputState::default();
743        let view = empty_view();
744        let actions = mapper.map(&alt(KeyCode::Enter), &view, "   ", &mut state, false);
745        assert!(actions.is_empty());
746    }
747
748    #[test]
749    fn alt_up_dequeues() {
750        let mapper = InputMapper::new();
751        let mut state = InputState::default();
752        let view = empty_view();
753        let actions = mapper.map(&alt(KeyCode::Up), &view, "", &mut state, false);
754        assert_eq!(actions, vec![ViewAction::DequeueFollowUp]);
755    }
756
757    #[test]
758    fn esc_dismisses_overlay_first() {
759        let mapper = InputMapper::new();
760        let mut state = InputState::default();
761        let view = view_with_overlay(OverlayKind::ShortcutHelp);
762        let actions = mapper.map(&plain(KeyCode::Esc), &view, "abc", &mut state, false);
763        assert_eq!(actions, vec![ViewAction::DismissOverlay]);
764    }
765
766    #[test]
767    fn esc_interrupts_when_streaming() {
768        let mapper = InputMapper::new();
769        let mut state = InputState::default();
770        let view = view_streaming();
771        let actions = mapper.map(&plain(KeyCode::Esc), &view, "x", &mut state, false);
772        assert_eq!(actions, vec![ViewAction::Interrupt]);
773    }
774
775    #[test]
776    fn esc_interrupts_when_compacting() {
777        let mapper = InputMapper::new();
778        let mut state = InputState::default();
779        let view = view_with_status(StatusKind::Compaction);
780        let actions = mapper.map(&plain(KeyCode::Esc), &view, "x", &mut state, false);
781        assert_eq!(actions, vec![ViewAction::Interrupt]);
782    }
783
784    #[test]
785    fn esc_clears_editor_when_nonempty_and_idle() {
786        let mapper = InputMapper::new();
787        let mut state = InputState::default();
788        let view = view_with_editor("draft");
789        let actions = mapper.map(&plain(KeyCode::Esc), &view, "draft", &mut state, false);
790        assert_eq!(actions, vec![ViewAction::ClearEditor]);
791    }
792
793    #[test]
794    fn esc_double_tap_on_empty_opens_tree_when_configured() {
795        let mapper = InputMapper::new();
796        let mut state = InputState::new(DoubleEscapeAction::Tree);
797        let view = empty_view();
798        let _ = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
799        let actions = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
800        assert_eq!(actions, vec![ViewAction::OpenTreeSelector]);
801    }
802
803    #[test]
804    fn esc_double_tap_on_empty_opens_fork_when_configured() {
805        let mapper = InputMapper::new();
806        let mut state = InputState::new(DoubleEscapeAction::Fork);
807        let view = empty_view();
808        let _ = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
809        let actions = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
810        assert_eq!(actions, vec![ViewAction::OpenForkSelector]);
811    }
812
813    #[test]
814    fn esc_single_tap_on_empty_when_double_disabled_is_noop() {
815        let mapper = InputMapper::new();
816        let mut state = InputState::default();
817        let view = empty_view();
818        let actions = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
819        assert!(actions.is_empty());
820        assert!(state.last_escape.is_none());
821    }
822
823    #[test]
824    fn esc_double_tap_outside_window_records_new_tap() {
825        let mapper = InputMapper::new();
826        let mut state = InputState::new(DoubleEscapeAction::Tree);
827        state.set_escape_double_window(Duration::from_nanos(1));
828        let view = empty_view();
829        let _ = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
830        std::thread::sleep(Duration::from_millis(2));
831        let actions = mapper.map(&plain(KeyCode::Esc), &view, "", &mut state, false);
832        assert!(actions.is_empty(), "{actions:?}");
833        assert!(state.last_escape.is_some());
834    }
835
836    #[test]
837    fn reset_taps_clears_state() {
838        let mut state = InputState {
839            last_sigint: Some(Instant::now()),
840            last_escape: Some(Instant::now()),
841            ..InputState::default()
842        };
843        state.reset_taps();
844        assert!(state.last_sigint.is_none());
845        assert!(state.last_escape.is_none());
846    }
847
848    #[test]
849    fn focus_events_produce_no_actions() {
850        let mapper = InputMapper::new();
851        let mut state = InputState::default();
852        let view = empty_view();
853        let actions = mapper.map(&UiEvent::FocusGained, &view, "", &mut state, false);
854        assert!(actions.is_empty());
855        let actions = mapper.map(&UiEvent::FocusLost, &view, "", &mut state, false);
856        assert!(actions.is_empty());
857    }
858
859    #[test]
860    fn editor_consumed_silences_app_keys() {
861        let mapper = InputMapper::new();
862        let mut state = InputState::default();
863        let view = empty_view();
864        let actions = mapper.map(&ctrl('l'), &view, "", &mut state, true);
865        assert!(actions.is_empty());
866    }
867
868    #[test]
869    fn double_escape_action_from_str_parses() {
870        assert_eq!(
871            double_escape_action_from_str("tree"),
872            DoubleEscapeAction::Tree
873        );
874        assert_eq!(
875            double_escape_action_from_str("fork"),
876            DoubleEscapeAction::Fork
877        );
878        assert_eq!(
879            double_escape_action_from_str("none"),
880            DoubleEscapeAction::None
881        );
882        assert_eq!(double_escape_action_from_str(""), DoubleEscapeAction::None);
883        assert_eq!(
884            double_escape_action_from_str("bogus"),
885            DoubleEscapeAction::None
886        );
887    }
888
889    #[test]
890    fn ctrl_v_emits_empty_paste_for_clipboard_image_path() {
891        let mapper = InputMapper::new();
892        let mut state = InputState::default();
893        let view = empty_view();
894        let actions = mapper.map(&ctrl('v'), &view, "", &mut state, false);
895        assert_eq!(
896            actions,
897            vec![ViewAction::Paste {
898                text: String::new()
899            }]
900        );
901    }
902
903    #[test]
904    fn unknown_key_is_ignored() {
905        let mapper = InputMapper::new();
906        let mut state = InputState::default();
907        let view = empty_view();
908        let actions = mapper.map(
909            &key(KeyCode::Char('a'), KeyModifiers::NONE),
910            &view,
911            "",
912            &mut state,
913            false,
914        );
915        assert!(actions.is_empty());
916    }
917
918    #[test]
919    fn focus_area_default_is_editor() {
920        let view = empty_view();
921        assert_eq!(view.focus, FocusArea::Editor);
922    }
923}