1use 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 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 if ctrl && matches!(key.code, KeyCode::Char('c' | 'd')) {
30 state.should_quit = true;
31 return;
32 }
33
34 if state.overlay.is_some() {
46 match state.overlay.as_mut() {
47 Some(ActiveOverlay::State | ActiveOverlay::Verdict(_) | ActiveOverlay::Risk { .. }) => {
48 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 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 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 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 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 if handle_scrollback(state, key.code, ctrl) {
131 return;
132 }
133
134 handle_prompt_edit(state, key.code, ctrl, shift);
136}
137
138fn 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 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 if !ctrl {
189 state.prompt.insert(c);
190 }
191 }
192 _ => {}
193 }
194}
195
196const SCROLL_PAGE_ROWS: u16 = 12;
201
202fn handle_scrollback(state: &mut AppState, code: KeyCode, ctrl: bool) -> bool {
206 match code {
207 KeyCode::PageUp => {
208 if ctrl {
209 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: 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 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 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 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 handle_key(&mut s, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
521 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 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 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}