Skip to main content

zero_tui/app/
input.rs

1//! Input translation — turn crossterm key events into app state
2//! mutations. Isolated from the event loop so unit tests can drive
3//! keystrokes directly against an `AppState`.
4
5use std::time::Instant;
6
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use crate::app::mode::Mode;
10use crate::app::state::{ActiveOverlay, AppState};
11
12pub fn handle_key(state: &mut AppState, key: KeyEvent) {
13    handle_key_inner(state, key);
14    // Picker mirrors the prompt — rebuild after any input event so
15    // the highlighted entry and list stay in sync with what the
16    // operator is typing. Cheap: the catalog is 14 entries.
17    state.refresh_picker();
18}
19
20fn handle_key_inner(state: &mut AppState, key: KeyEvent) {
21    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
22    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
23
24    // Ctrl+C, Ctrl+D: exit. No confirmation — this is a terminal,
25    // not a dialog box. Risk-reducing exit is always instant per
26    // the risk asymmetry (ADR-014). Also exits the overlay path,
27    // since a modal that can trap a quit would violate the
28    // "risk-reducing actions are frictionless" rule.
29    if ctrl && matches!(key.code, KeyCode::Char('c' | 'd')) {
30        state.should_quit = true;
31        return;
32    }
33
34    // Modal overlays get priority over the prompt. Each variant
35    // has its own affordance:
36    //
37    // * `State` — information-only. Any key dismisses. The gate
38    //   contract the user is used to.
39    // * `FrictionPause` — gated. Esc cancels (drops the pending
40    //   command). At L1 the pause alone is the gate, so any other
41    //   key is ignored. At L2+ typed characters feed the confirm
42    //   buffer once the mandatory pause has elapsed; Backspace
43    //   edits; Enter is a no-op (completion is detected by the
44    //   event loop via `AppState::take_confirmed_friction_command`).
45    if state.overlay.is_some() {
46        match state.overlay.as_mut() {
47            Some(ActiveOverlay::State | ActiveOverlay::Verdict(_) | ActiveOverlay::Risk { .. }) => {
48                // Data overlays — same dismissal contract as the
49                // state overview: any key closes. The verdict
50                // overlay is read-only too; the `Evaluation`
51                // payload is ephemeral (not kept after dismiss),
52                // matching how the state overlay re-reads the
53                // mirror every render. The Risk overlay (M2 §4)
54                // also dismisses on any key; `dismiss_overlay`
55                // records the dismissal timestamp so the auto-
56                // open hook can enforce the 60 s cooldown.
57                state.dismiss_overlay();
58                return;
59            }
60            Some(ActiveOverlay::FrictionPause(fp)) => {
61                if matches!(key.code, KeyCode::Esc) {
62                    state.dismiss_overlay();
63                    return;
64                }
65                // Ctrl+anything else at the friction overlay is
66                // swallowed — the overlay is modal and we do not
67                // want stray mode switches / splits through a
68                // pending gate. Ctrl+C already exited above.
69                if ctrl {
70                    return;
71                }
72                let now = Instant::now();
73                match key.code {
74                    KeyCode::Char(c) if !ctrl => fp.push_char(c, now),
75                    KeyCode::Backspace => fp.pop_char(now),
76                    _ => {}
77                }
78                return;
79            }
80            None => unreachable!("overlay.is_some() established above"),
81        }
82    }
83
84    // Mode switchers: Ctrl+0..5.
85    if ctrl
86        && let KeyCode::Char(c) = key.code
87        && let Some(d) = c.to_digit(10)
88        && let Ok(d) = u8::try_from(d)
89        && let Some(mode) = Mode::from_digit(d)
90    {
91        state.mode = mode;
92        return;
93    }
94
95    // Ctrl+R toggles screen-reader mode. Log a single system row
96    // so the operator has a visible confirmation; the row itself
97    // renders through the new mode so it also serves as a smoke
98    // test of the alternate path.
99    if ctrl && matches!(key.code, KeyCode::Char('r')) {
100        let on = state.toggle_screen_reader();
101        state.push_system(if on {
102            "[system] screen-reader mode on (Ctrl+R to toggle)"
103        } else {
104            "[system] screen-reader mode off (Ctrl+R to toggle)"
105        });
106        return;
107    }
108
109    // Alt+] toggles the live-stream pane. Using the Alt modifier
110    // (rather than a bare `]`) avoids clashing with operators
111    // typing `]` into the prompt — the trading log has enough
112    // hazards without a keystroke ambiguity. A confirmation row
113    // in the conversation log is important the first few times
114    // so the operator knows the toggle fired even when the pane
115    // had nothing to render.
116    if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char(']')) {
117        let on = state.toggle_live_stream();
118        state.push_system(if on {
119            "[system] live-stream pane on (Alt+] to toggle)"
120        } else {
121            "[system] live-stream pane off (Alt+] to toggle)"
122        });
123        return;
124    }
125
126    // Scrollback: PageUp/PageDown walk one "page" (12 rows is
127    // enough to be useful on a short terminal and not too much on
128    // a tall one). Ctrl+PageUp / Ctrl+PageDown jump to top/bottom
129    // — the bottom jump re-attaches to the live tail.
130    if handle_scrollback(state, key.code, ctrl) {
131        return;
132    }
133
134    // Default: prompt editing, with picker-aware Up/Down/Tab.
135    handle_prompt_edit(state, key.code, ctrl, shift);
136}
137
138/// Prompt-editing branch of [`handle_key_inner`]. Picker-aware:
139/// `Up/Down` move selection inside an active picker, `Tab`
140/// completes the highlighted entry, `Enter` submits (and
141/// `Shift+Enter` inserts a newline).
142fn handle_prompt_edit(state: &mut AppState, code: KeyCode, ctrl: bool, shift: bool) {
143    match code {
144        KeyCode::Enter => {
145            if shift {
146                state.prompt.insert_newline();
147            } else {
148                state.submit_prompt();
149            }
150        }
151        KeyCode::Tab => {
152            if let Some(picker) = state.picker.as_ref()
153                && let Some(text) = picker.completion_text()
154            {
155                state.prompt.replace_all(&text);
156            }
157        }
158        KeyCode::Up => {
159            // Routing: picker > multi-row nav > history recall.
160            if let Some(picker) = state.picker.as_mut() {
161                picker.select_prev();
162            } else if state.prompt.cursor_on_first_row() {
163                state.prompt.recall_prev();
164            } else {
165                state.prompt.move_up();
166            }
167        }
168        KeyCode::Down => {
169            if let Some(picker) = state.picker.as_mut() {
170                picker.select_next();
171            } else if state.prompt.cursor_on_last_row() {
172                state.prompt.recall_next();
173            } else {
174                state.prompt.move_down();
175            }
176        }
177        KeyCode::Backspace => state.prompt.backspace(),
178        KeyCode::Delete => state.prompt.delete(),
179        KeyCode::Left => state.prompt.move_left(),
180        KeyCode::Right => state.prompt.move_right(),
181        KeyCode::Home => state.prompt.move_home(),
182        KeyCode::End => state.prompt.move_end(),
183        KeyCode::Esc => state.prompt.clear(),
184        KeyCode::Char(c) => {
185            // Strip Ctrl+Char where we didn't handle it above —
186            // we don't want spurious chars leaking into the
187            // prompt.
188            if !ctrl {
189                state.prompt.insert(c);
190            }
191        }
192        _ => {}
193    }
194}
195
196/// Scrollback step size for PageUp/PageDown. A dozen rows is a
197/// comfortable scan speed on a 24-row pane and barely shifts on a
198/// 60-row one — in both cases the operator sees context, not a
199/// full flip.
200const SCROLL_PAGE_ROWS: u16 = 12;
201
202/// Scrollback key handler split out of [`handle_key_inner`] to
203/// keep the dispatcher under the clippy line budget. Returns
204/// `true` when the key was handled here.
205fn handle_scrollback(state: &mut AppState, code: KeyCode, ctrl: bool) -> bool {
206    match code {
207        KeyCode::PageUp => {
208            if ctrl {
209                // Jump toward oldest. `u16::MAX` is effectively
210                // unbounded relative to a 2048-cap log.
211                state.scroll_log_up(u16::MAX);
212            } else {
213                state.scroll_log_up(SCROLL_PAGE_ROWS);
214            }
215            true
216        }
217        KeyCode::PageDown => {
218            if ctrl {
219                state.scroll_log_to_bottom();
220            } else {
221                state.scroll_log_down(SCROLL_PAGE_ROWS);
222            }
223            true
224        }
225        _ => false,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::handle_key;
232    use crate::app::mode::Mode;
233    use crate::app::state::{ActiveOverlay, AppState};
234    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
235    use zero_engine_client::EngineState;
236
237    fn mk() -> AppState {
238        AppState::new(EngineState::shared())
239    }
240
241    #[test]
242    fn typing_appends_to_prompt() {
243        let mut s = mk();
244        for c in "hi".chars() {
245            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
246        }
247        assert_eq!(s.prompt.as_string(), "hi");
248    }
249
250    #[test]
251    fn enter_submits_and_clears() {
252        let mut s = mk();
253        for c in "/help".chars() {
254            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
255        }
256        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
257        assert!(s.prompt.is_empty());
258    }
259
260    #[test]
261    fn ctrl_c_quits() {
262        let mut s = mk();
263        handle_key(
264            &mut s,
265            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
266        );
267        assert!(s.should_quit);
268    }
269
270    #[test]
271    fn ctrl_digit_switches_mode() {
272        let mut s = mk();
273        handle_key(
274            &mut s,
275            KeyEvent::new(KeyCode::Char('2'), KeyModifiers::CONTROL),
276        );
277        assert_eq!(s.mode, Mode::Positions);
278        handle_key(
279            &mut s,
280            KeyEvent::new(KeyCode::Char('4'), KeyModifiers::CONTROL),
281        );
282        assert_eq!(s.mode, Mode::Heat);
283        handle_key(
284            &mut s,
285            KeyEvent::new(KeyCode::Char('0'), KeyModifiers::CONTROL),
286        );
287        assert_eq!(s.mode, Mode::Conversation);
288    }
289
290    #[test]
291    fn overlay_dismisses_on_any_key() {
292        use crate::app::state::ActiveOverlay;
293        let mut s = mk();
294        s.overlay = Some(ActiveOverlay::State);
295        handle_key(
296            &mut s,
297            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
298        );
299        assert!(s.overlay.is_none());
300        assert!(
301            s.prompt.is_empty(),
302            "key that closes the overlay must not leak into prompt"
303        );
304    }
305
306    #[test]
307    fn overlay_does_not_trap_ctrl_c() {
308        use crate::app::state::ActiveOverlay;
309        let mut s = mk();
310        s.overlay = Some(ActiveOverlay::State);
311        handle_key(
312            &mut s,
313            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
314        );
315        assert!(s.should_quit, "Ctrl+C must exit even through an overlay");
316    }
317
318    #[test]
319    fn verdict_overlay_dismisses_on_any_key() {
320        use crate::app::state::ActiveOverlay;
321        use zero_engine_client::Evaluation;
322        let mut s = mk();
323        s.overlay = Some(ActiveOverlay::Verdict(Box::new(Evaluation {
324            coin: Some("BTC".into()),
325            direction: Some("LONG".into()),
326            ..Default::default()
327        })));
328        handle_key(
329            &mut s,
330            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
331        );
332        assert!(s.overlay.is_none(), "verdict overlay must dismiss");
333        assert!(
334            s.prompt.is_empty(),
335            "dismissing keystroke must not leak into prompt"
336        );
337    }
338
339    #[test]
340    fn verdict_overlay_survives_ctrl_c_exit() {
341        use crate::app::state::ActiveOverlay;
342        use zero_engine_client::Evaluation;
343        let mut s = mk();
344        s.overlay = Some(ActiveOverlay::Verdict(Box::<Evaluation>::default()));
345        handle_key(
346            &mut s,
347            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
348        );
349        assert!(
350            s.should_quit,
351            "Ctrl+C must still exit through a verdict overlay"
352        );
353    }
354
355    #[test]
356    fn overlay_dismiss_swallows_ctrl_digit_mode_switch() {
357        use crate::app::state::ActiveOverlay;
358        let mut s = mk();
359        s.mode = Mode::Conversation;
360        s.overlay = Some(ActiveOverlay::State);
361        handle_key(
362            &mut s,
363            KeyEvent::new(KeyCode::Char('2'), KeyModifiers::CONTROL),
364        );
365        assert!(s.overlay.is_none(), "overlay should be dismissed");
366        assert_eq!(
367            s.mode,
368            Mode::Conversation,
369            "the dismissing keystroke must not double-fire as a mode switch"
370        );
371    }
372
373    #[test]
374    fn friction_overlay_esc_cancels_and_drops_command() {
375        use crate::app::state::{ActiveOverlay, FrictionPause};
376        use std::time::{Duration, Instant};
377        use zero_commands::Command;
378        use zero_operator_state::friction::FrictionLevel;
379        let mut s = mk();
380        s.overlay = Some(ActiveOverlay::FrictionPause(FrictionPause {
381            command: Command::Execute,
382            level: FrictionLevel::L1,
383            started_at: Instant::now(),
384            pause: Duration::from_secs(3),
385            confirm_word: None,
386            confirm_input: String::new(),
387        }));
388        handle_key(&mut s, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
389        assert!(s.overlay.is_none(), "Esc at friction overlay cancels it");
390    }
391
392    #[test]
393    fn friction_overlay_l1_ignores_typed_keys() {
394        use crate::app::state::{ActiveOverlay, FrictionPause};
395        use std::time::{Duration, Instant};
396        use zero_commands::Command;
397        use zero_operator_state::friction::FrictionLevel;
398        let mut s = mk();
399        s.overlay = Some(ActiveOverlay::FrictionPause(FrictionPause {
400            command: Command::Execute,
401            level: FrictionLevel::L1,
402            started_at: Instant::now(),
403            pause: Duration::from_secs(3),
404            confirm_word: None,
405            confirm_input: String::new(),
406        }));
407        handle_key(
408            &mut s,
409            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
410        );
411        assert!(
412            s.overlay.is_some(),
413            "typed chars at L1 must not dismiss the overlay"
414        );
415        assert!(
416            s.prompt.is_empty(),
417            "typed chars at L1 must not leak into prompt"
418        );
419    }
420
421    #[test]
422    fn friction_overlay_l2_does_not_accept_typing_during_pause() {
423        use crate::app::state::{ActiveOverlay, FrictionPause};
424        use std::time::{Duration, Instant};
425        use zero_commands::Command;
426        use zero_operator_state::friction::FrictionLevel;
427        let mut s = mk();
428        s.overlay = Some(ActiveOverlay::FrictionPause(FrictionPause {
429            command: Command::Execute,
430            level: FrictionLevel::L2,
431            started_at: Instant::now(),
432            pause: Duration::from_secs(10),
433            confirm_word: Some("execute".into()),
434            confirm_input: String::new(),
435        }));
436        for c in "execute".chars() {
437            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
438        }
439        if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
440            assert!(
441                fp.confirm_input.is_empty(),
442                "mandatory pause must reject typing; got {:?}",
443                fp.confirm_input
444            );
445        } else {
446            panic!("overlay was dismissed unexpectedly");
447        }
448    }
449
450    #[test]
451    fn friction_overlay_l2_accepts_typing_after_pause() {
452        use crate::app::state::{ActiveOverlay, FrictionPause};
453        use std::time::{Duration, Instant};
454        use zero_commands::Command;
455        use zero_operator_state::friction::FrictionLevel;
456        let mut s = mk();
457        s.overlay = Some(ActiveOverlay::FrictionPause(FrictionPause {
458            command: Command::Execute,
459            level: FrictionLevel::L2,
460            // started_at in the past so the pause is already done
461            // at the time the event loop fires the next key event.
462            started_at: Instant::now()
463                .checked_sub(Duration::from_secs(11))
464                .expect("monotonic Instant supports 11s subtraction"),
465            pause: Duration::from_secs(10),
466            confirm_word: Some("execute".into()),
467            confirm_input: String::new(),
468        }));
469        for c in "exec".chars() {
470            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
471        }
472        if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
473            assert_eq!(fp.confirm_input, "exec");
474        } else {
475            panic!("overlay dismissed unexpectedly");
476        }
477        // Backspace edits the confirm buffer, not the prompt.
478        handle_key(
479            &mut s,
480            KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
481        );
482        if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
483            assert_eq!(fp.confirm_input, "exe");
484        }
485    }
486
487    #[test]
488    fn shift_enter_inserts_newline_instead_of_submitting() {
489        let mut s = mk();
490        for c in "abc".chars() {
491            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
492        }
493        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
494        for c in "def".chars() {
495            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
496        }
497        assert_eq!(s.prompt.as_string(), "abc\ndef");
498        assert!(s.pending_input.is_none(), "Shift+Enter must not submit");
499        // Plain Enter now submits the joined buffer.
500        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
501        assert_eq!(s.pending_input.as_deref(), Some("abc\ndef"));
502    }
503
504    #[test]
505    fn up_recalls_previous_history_when_on_first_row() {
506        let mut s = mk();
507        for c in "/status".chars() {
508            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
509        }
510        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
511        // pending_input is drained by the event loop in prod; clear it
512        // here to reset submission state.
513        s.pending_input = None;
514        for c in "/risk".chars() {
515            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
516        }
517        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
518        s.pending_input = None;
519        // Buffer is empty; Up should recall the newest entry.
520        handle_key(&mut s, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
521        // After recall, the buffer starts with `/` so the picker
522        // is active — Up now navigates the picker, not history.
523        assert_eq!(s.prompt.as_string(), "/risk");
524    }
525
526    #[test]
527    fn up_navigates_picker_when_active() {
528        let mut s = mk();
529        for c in "/".chars() {
530            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
531        }
532        assert!(s.picker.is_some(), "typing / must open the picker");
533        let first_selected = s.picker.as_ref().unwrap().selected_index();
534        handle_key(&mut s, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
535        assert_ne!(
536            s.picker.as_ref().unwrap().selected_index(),
537            first_selected,
538            "Down with active picker should move selection"
539        );
540    }
541
542    #[test]
543    fn tab_completes_selected_picker_entry() {
544        let mut s = mk();
545        for c in "/he".chars() {
546            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
547        }
548        handle_key(&mut s, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
549        assert_eq!(s.prompt.as_string(), "/help ");
550    }
551
552    #[test]
553    fn esc_clears_prompt_and_picker_together() {
554        let mut s = mk();
555        for c in "/h".chars() {
556            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
557        }
558        assert!(s.picker.is_some());
559        handle_key(&mut s, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
560        assert_eq!(s.prompt.as_string(), "");
561        assert!(
562            s.picker.is_none(),
563            "clearing the buffer must also dismiss the ambient picker"
564        );
565    }
566
567    #[test]
568    fn pageup_detaches_pagedown_reattaches_scrollback() {
569        let mut s = mk();
570        for i in 0..30 {
571            s.push_system(format!("row {i}"));
572        }
573        assert_eq!(s.log_scroll, 0);
574        handle_key(&mut s, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
575        assert!(s.log_scroll > 0, "PageUp must detach the viewport");
576        handle_key(
577            &mut s,
578            KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL),
579        );
580        assert_eq!(s.log_scroll, 0, "Ctrl+PageDown re-attaches to bottom");
581    }
582
583    #[test]
584    fn ctrl_r_toggles_screen_reader_mode() {
585        let mut s = mk();
586        assert!(!s.screen_reader);
587        handle_key(
588            &mut s,
589            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
590        );
591        assert!(s.screen_reader);
592        handle_key(
593            &mut s,
594            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
595        );
596        assert!(!s.screen_reader);
597    }
598
599    #[test]
600    fn submit_detaches_scroll_if_scrolled_up() {
601        let mut s = mk();
602        for i in 0..30 {
603            s.push_system(format!("row {i}"));
604        }
605        s.scroll_log_up(10);
606        assert_eq!(s.log_scroll, 10);
607        for c in "/status".chars() {
608            handle_key(&mut s, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
609        }
610        handle_key(&mut s, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
611        assert_eq!(
612            s.log_scroll, 0,
613            "submit should re-attach to bottom so command output is visible"
614        );
615    }
616
617    #[test]
618    fn alt_right_bracket_toggles_live_stream_pane() {
619        let mut s = mk();
620        assert!(!s.live_stream_visible);
621        handle_key(&mut s, KeyEvent::new(KeyCode::Char(']'), KeyModifiers::ALT));
622        assert!(
623            s.live_stream_visible,
624            "Alt+] should turn the pane on from the hidden default"
625        );
626        handle_key(&mut s, KeyEvent::new(KeyCode::Char(']'), KeyModifiers::ALT));
627        assert!(
628            !s.live_stream_visible,
629            "second Alt+] should turn the pane off again"
630        );
631    }
632
633    #[test]
634    fn bare_right_bracket_is_typed_into_prompt_not_a_toggle() {
635        // The toggle is deliberately bound to Alt+] — a bare `]`
636        // in the prompt must flow through to the buffer. This is
637        // the conflict we chose the modifier to avoid.
638        let mut s = mk();
639        handle_key(
640            &mut s,
641            KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE),
642        );
643        assert!(!s.live_stream_visible, "bare `]` must not toggle the pane");
644        assert_eq!(
645            s.prompt.as_string(),
646            "]",
647            "bare `]` must land in the prompt buffer"
648        );
649    }
650
651    #[test]
652    fn alt_right_bracket_inside_overlay_is_swallowed() {
653        // Any modal overlay takes priority: Alt+] must not sneak
654        // through and toggle the pane while the operator is
655        // reading a state/verdict/friction overlay.
656        let mut s = mk();
657        s.overlay = Some(ActiveOverlay::State);
658        handle_key(&mut s, KeyEvent::new(KeyCode::Char(']'), KeyModifiers::ALT));
659        assert!(
660            !s.live_stream_visible,
661            "overlays swallow keys — toggle must not fire"
662        );
663    }
664
665    #[test]
666    fn ctrl_digit_five_opens_cockpit_mode() {
667        let mut s = mk();
668        s.mode = Mode::Decisions;
669        handle_key(
670            &mut s,
671            KeyEvent::new(KeyCode::Char('5'), KeyModifiers::CONTROL),
672        );
673        assert_eq!(s.mode, Mode::Cockpit, "Ctrl+5 must open cockpit mode");
674    }
675
676    #[test]
677    fn ctrl_digit_six_is_unbound() {
678        let mut s = mk();
679        s.mode = Mode::Decisions;
680        handle_key(
681            &mut s,
682            KeyEvent::new(KeyCode::Char('6'), KeyModifiers::CONTROL),
683        );
684        assert_eq!(s.mode, Mode::Decisions, "Ctrl+6 must not change mode");
685    }
686}