返回 CodeWhale
shell_key_routing.rs
根目录 / crates / tui / src / tui / shell_key_routing.rs
1 //! Shell keyboard bindings for details / context / help.
2 //!
3 //! Footer hints, help catalog chords, and live handlers must agree on one
4 //! source. Outside exclusive consent gates, printable characters belong to
5 //! the composer: bare `v` types `v` in every ordinary focus state — work surface, transcript selection,
6 //! panel, or modal (TUI-DOG-002). Details/output fires only on
7 //! Option+V / Alt+V, and macOS renders the label as `⌥V`, never `Alt`/`Cmd`.
8 //! Help answers to `F1` and `Ctrl+/` (with `/help`); chrome advertises only
9 //! `Ctrl+/`, the chord every terminal delivers.
10 //! Provider/route is `F3` (with `/provider`); it is non-printable so it can
11 //! remain available while the composer owns ordinary text input.
12 //! `Alt+?` and `Alt+C` are still accepted where terminals deliver them but
13 //! are never advertised until proven in real terminals (TUI-DOG-003);
14 //! `/context` is the guaranteed context path.
15 //! Ambiguous macOS Option glyphs (`ç` / `¿`) remain text: terminals do not
16 //! identify whether they came from Option or from a user's keyboard layout.
17
18 use std::borrow::Cow;
19
20 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
21
22 use crate::tui::key_shortcuts;
23 use crate::tui::views::ModalKind;
24
25 /// Who owns the keyboard right now.
26 ///
27 /// One value, derived in one place ([`crate::tui::app::App::focus`]), in
28 /// place of the `app.input.is_empty()` guesses that used composer *content*
29 /// as a stand-in for composer *focus*. Composer editing keys still ask about
30 /// the text itself; every shell binding asks this instead.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 pub enum Focus {
33 /// The model-bound redaction consent gate exclusively owns its decision.
34 RedactionGate,
35 /// The onboarding rail owns every key until it finishes.
36 Onboarding,
37 /// A modal view is on top of the stack and handles its own keys.
38 Modal(ModalKind),
39 /// The pre-session launch screen — its menu or its composer.
40 Launch,
41 /// A focused rail or workflow panel inside a live session.
42 Panel,
43 /// The session composer: the default owner.
44 Composer,
45 }
46
47 /// Which focus states a binding is live in — the `ShellBinding` focus rule
48 /// that used to be re-invented at every call site as
49 /// `&& app.view_stack.is_empty()`. The variants nest: each admits everything
50 /// the one above it does, plus one more surface. The redaction consent gate
51 /// is exclusive and sits outside that shell hierarchy.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53 pub enum FocusScope {
54 PetHabitat,
55 /// Only the model-bound redaction consent gate.
56 RedactionGate,
57 /// A live session: the composer, or a rail/workflow panel that has taken
58 /// the keys from it.
59 SessionShell,
60 /// [`FocusScope::SessionShell`], plus the pre-session launch stage.
61 AnyShell,
62 /// [`FocusScope::AnyShell`], plus the Config modal, which displays the
63 /// very setting the binding changes.
64 AnyShellOrConfig,
65 /// Every ordinary shell state, onboarding and modals included. Exclusive
66 /// consent gates keep their own keys.
67 Everywhere,
68 }
69
70 impl FocusScope {
71 #[must_use]
72 pub fn admits(self, focus: Focus) -> bool {
73 match self {
74 Self::PetHabitat => focus == Focus::Modal(ModalKind::PetHabitat),
75 Self::RedactionGate => focus == Focus::RedactionGate,
76 Self::SessionShell => matches!(focus, Focus::Composer | Focus::Panel),
77 Self::AnyShell => matches!(focus, Focus::Composer | Focus::Panel | Focus::Launch),
78 Self::AnyShellOrConfig => matches!(
79 focus,
80 Focus::Composer | Focus::Panel | Focus::Launch | Focus::Modal(ModalKind::Config)
81 ),
82 Self::Everywhere => focus != Focus::RedactionGate,
83 }
84 }
85 }
86
87 /// Stable binding ids shared by handlers, footer hints, and help catalog.
88 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
89 pub enum ShellBindingId {
90 PetResultUp,
91 PetResultDown,
92 PetResultPageUp,
93 PetResultPageDown,
94 PetBack,
95 PetSound,
96 PetBrowser,
97 PetWindow,
98 RedactionGateConfirm,
99 RedactionGateKeepOrBack,
100 RedactionGateQuit,
101 RedactionGateScroll,
102 ToolDetails,
103 ContextInspector,
104 ProviderRoute,
105 Help,
106 Settings,
107 /// Tab: cycle the session mode.
108 ModeCycle,
109 /// Shift+Tab: cycle the permission posture.
110 PermissionCycle,
111 /// Ctrl+Tab / Ctrl+]: the next bottom-dock view.
112 ViewCycle,
113 /// Ctrl+Shift+Tab: the previous bottom-dock view.
114 ViewCycleBack,
115 }
116
117 /// One advertised binding with the portable catalog chord and focus rules.
118 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119 pub struct ShellBinding {
120 pub id: ShellBindingId,
121 /// Chord shown in help / documentation (portable Alt form; macOS
122 /// substitutes `⌥` at render time via [`display_chord`]).
123 pub catalog_chord: &'static str,
124 /// Compact footer chord when this binding is advertised.
125 pub footer_chord: &'static str,
126 /// The focus states this binding is live in. Never composer content:
127 /// a shell binding does the same thing whether or not you have typed.
128 pub focus: FocusScope,
129 }
130
131 impl ShellBinding {
132 /// Does this key press this binding, ignoring focus?
133 #[must_use]
134 pub fn matches(&self, key: &KeyEvent) -> bool {
135 match self.id {
136 ShellBindingId::PetResultUp => key.code == KeyCode::Up && key.modifiers.is_empty(),
137 ShellBindingId::PetResultDown => key.code == KeyCode::Down && key.modifiers.is_empty(),
138 ShellBindingId::PetResultPageUp => {
139 key.code == KeyCode::PageUp && key.modifiers.is_empty()
140 }
141 ShellBindingId::PetResultPageDown => {
142 key.code == KeyCode::PageDown && key.modifiers.is_empty()
143 }
144 ShellBindingId::PetBack => key.code == KeyCode::Esc && key.modifiers.is_empty(),
145 ShellBindingId::PetSound => key.code == KeyCode::F(6) && key.modifiers.is_empty(),
146 ShellBindingId::PetBrowser => key.code == KeyCode::F(8) && key.modifiers.is_empty(),
147 ShellBindingId::PetWindow => key.code == KeyCode::F(9) && key.modifiers.is_empty(),
148
149 ShellBindingId::RedactionGateConfirm => is_redaction_gate_choice(key, '1', 'y'),
150 ShellBindingId::RedactionGateKeepOrBack => is_redaction_gate_choice(key, '2', 'u'),
151 ShellBindingId::RedactionGateQuit => is_redaction_gate_choice(key, '3', 'n'),
152 ShellBindingId::RedactionGateScroll => {
153 key.modifiers.is_empty()
154 && matches!(
155 key.code,
156 KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown
157 )
158 }
159 ShellBindingId::ToolDetails => is_tool_details_shortcut(key),
160 ShellBindingId::ContextInspector => is_context_inspector_shortcut(key),
161 ShellBindingId::ProviderRoute => is_provider_route_shortcut(key),
162 ShellBindingId::Help => is_help_shortcut(key),
163 ShellBindingId::Settings => is_settings_shortcut(key),
164 ShellBindingId::ModeCycle => is_mode_cycle_shortcut(key),
165 ShellBindingId::PermissionCycle => is_permission_cycle_shortcut(key),
166 ShellBindingId::ViewCycle => is_view_cycle_shortcut(key),
167 ShellBindingId::ViewCycleBack => is_view_cycle_back_shortcut(key),
168 }
169 }
170 }
171
172 /// The shell's single key-admission authority: which binding, if any, this
173 /// key presses for this focus owner.
174 ///
175 /// Callers keep their position in the event loop — that ordering is a real
176 /// statement about which surface sees a key first — but none of them decides
177 /// admission any more, and none of them may ask about composer content.
178 #[must_use]
179 pub fn route(focus: Focus, key: &KeyEvent) -> Option<ShellBindingId> {
180 SHELL_BINDINGS
181 .iter()
182 .find(|binding| binding.focus.admits(focus) && binding.matches(key))
183 .map(|binding| binding.id)
184 }
185
186 /// Canonical shell bindings. Handlers and chrome read from here.
187 pub const SHELL_BINDINGS: &[ShellBinding] = &[
188 ShellBinding {
189 id: ShellBindingId::PetResultUp,
190 catalog_chord: "Up",
191 footer_chord: "↑",
192 focus: FocusScope::PetHabitat,
193 },
194 ShellBinding {
195 id: ShellBindingId::PetResultDown,
196 catalog_chord: "Down",
197 footer_chord: "↓",
198 focus: FocusScope::PetHabitat,
199 },
200 ShellBinding {
201 id: ShellBindingId::PetResultPageUp,
202 catalog_chord: "PgUp",
203 footer_chord: "PgUp",
204 focus: FocusScope::PetHabitat,
205 },
206 ShellBinding {
207 id: ShellBindingId::PetResultPageDown,
208 catalog_chord: "PgDn",
209 footer_chord: "PgDn",
210 focus: FocusScope::PetHabitat,
211 },
212 ShellBinding {
213 id: ShellBindingId::PetBack,
214 catalog_chord: "Esc",
215 footer_chord: "Esc",
216 focus: FocusScope::PetHabitat,
217 },
218 ShellBinding {
219 id: ShellBindingId::PetSound,
220 catalog_chord: "F6",
221 footer_chord: "F6",
222 focus: FocusScope::PetHabitat,
223 },
224 ShellBinding {
225 id: ShellBindingId::PetBrowser,
226 catalog_chord: "F8",
227 footer_chord: "F8",
228 focus: FocusScope::PetHabitat,
229 },
230 ShellBinding {
231 id: ShellBindingId::PetWindow,
232 catalog_chord: "F9",
233 footer_chord: "F9",
234 focus: FocusScope::PetHabitat,
235 },
236 ShellBinding {
237 id: ShellBindingId::RedactionGateConfirm,
238 catalog_chord: "1/Y",
239 footer_chord: "1/Y",
240 focus: FocusScope::RedactionGate,
241 },
242 ShellBinding {
243 id: ShellBindingId::RedactionGateKeepOrBack,
244 catalog_chord: "2/U",
245 footer_chord: "2/U",
246 focus: FocusScope::RedactionGate,
247 },
248 ShellBinding {
249 id: ShellBindingId::RedactionGateQuit,
250 catalog_chord: "3/N",
251 footer_chord: "3/N",
252 focus: FocusScope::RedactionGate,
253 },
254 ShellBinding {
255 id: ShellBindingId::RedactionGateScroll,
256 catalog_chord: "↑/↓",
257 footer_chord: "↑/↓",
258 focus: FocusScope::RedactionGate,
259 },
260 ShellBinding {
261 id: ShellBindingId::ToolDetails,
262 catalog_chord: "Alt+V",
263 footer_chord: "Alt+V",
264 // The rail claims the details chord for a selected row before the
265 // transcript pager sees it; both are session surfaces.
266 focus: FocusScope::SessionShell,
267 },
268 ShellBinding {
269 id: ShellBindingId::ContextInspector,
270 // `/context` is the guaranteed path; Alt+C stays an unadvertised
271 // handler until proven in Cursor/Terminal.app/iTerm2/tmux/PTY.
272 catalog_chord: "/context",
273 footer_chord: "/context",
274 focus: FocusScope::SessionShell,
275 },
276 ShellBinding {
277 id: ShellBindingId::ProviderRoute,
278 // `/provider` remains the portable, explicit command path.
279 catalog_chord: "F3 / /provider",
280 footer_chord: "F3",
281 // The route is also pickable before a session exists.
282 focus: FocusScope::AnyShell,
283 },
284 ShellBinding {
285 id: ShellBindingId::Help,
286 // `/help` also opens this. F1 is accepted but never advertised on
287 // chrome: tmux and several emulators eat it (see
288 // [`HELP_CHROME_CHORD`]).
289 catalog_chord: "F1 / Ctrl+/",
290 footer_chord: HELP_CHROME_CHORD,
291 focus: FocusScope::Everywhere,
292 },
293 ShellBinding {
294 id: ShellBindingId::Settings,
295 catalog_chord: "F2",
296 footer_chord: "F2",
297 // Shell-global for the same reason as Help: a settings route that
298 // disappears inside onboarding or a modal is not a route.
299 focus: FocusScope::Everywhere,
300 },
301 ShellBinding {
302 id: ShellBindingId::ModeCycle,
303 catalog_chord: "Tab",
304 footer_chord: "Tab",
305 // Tab is the shell's mode cycle. The composer's own completions
306 // get the key first, but *having typed* never disables it. Live on
307 // the launch screen too: the card's rows are arrowed, the composer
308 // is always focused, so Tab had no focus left to move and read as
309 // dead (0.9.12 defect #5).
310 focus: FocusScope::AnyShell,
311 },
312 ShellBinding {
313 id: ShellBindingId::PermissionCycle,
314 catalog_chord: "Shift+Tab",
315 footer_chord: "Shift+Tab",
316 // A shell-level permission control, live wherever the shell is —
317 // including the launch screen, where it used to be dead — plus the
318 // Config modal that displays the posture it changes.
319 focus: FocusScope::AnyShellOrConfig,
320 },
321 ShellBinding {
322 id: ShellBindingId::ViewCycle,
323 // Ctrl+Tab only arrives under the kitty keyboard protocol (the loop
324 // pushes DISAMBIGUATE_ESCAPE_CODES); Ctrl+] is the chord every
325 // terminal delivers — unbound here, not eaten by VS Code/Cursor
326 // (Ctrl+F is), tmux, iTerm2, Terminal.app, or Windows Terminal.
327 catalog_chord: "Ctrl+Tab / Ctrl+]",
328 footer_chord: "Ctrl+]",
329 // The launch card advertises this same work-bar control.
330 focus: FocusScope::AnyShell,
331 },
332 ShellBinding {
333 id: ShellBindingId::ViewCycleBack,
334 catalog_chord: "Ctrl+Shift+Tab",
335 footer_chord: "Ctrl+Shift+Tab",
336 focus: FocusScope::AnyShell,
337 },
338 ];
339
340 fn is_redaction_gate_choice(key: &KeyEvent, digit: char, letter: char) -> bool {
341 // Modifiers must not turn an unrelated shortcut into consent. Shift is
342 // accepted for the uppercase letter advertised in the action rail.
343 (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
344 && matches!(key.code, KeyCode::Char(ch) if ch == digit || ch.eq_ignore_ascii_case(&letter))
345 }
346
347 /// The chord the info line advertises for help.
348 ///
349 /// Not `F1`: tmux, screen, and several terminal emulators claim it before the
350 /// shell ever sees the key, so an info line that printed `F1 help` would be
351 /// advertising a key that does nothing for many users. Not `?` either — bare
352 /// `?` is composer text in every focus state, and help only answers to
353 /// `Alt+?`, which stays unadvertised until it is proven in real terminals
354 /// (TUI-DOG-003). `Ctrl+/` remains accepted, together with its legacy
355 /// `Ctrl+7` / `Ctrl+_` encodings, but it is no longer what chrome advertises:
356 /// how a terminal encodes Ctrl+/ varies enough that the printed hint was a
357 /// promise the product could not keep on the founder's own machine. `/help`
358 /// is a slash command — it reaches the same view through the composer, it
359 /// works in every terminal, and typing `/` already reveals it.
360 pub const HELP_CHROME_CHORD: &str = "/help";
361
362 /// The info line's single right-hand key hint.
363 ///
364 /// A slash command names itself, so it prints bare (`/help`); a key chord
365 /// still needs the word (`Ctrl+/ help`).
366 #[must_use]
367 pub fn info_help_hint(locale: codewhale_localization::Locale) -> String {
368 let chord = binding(ShellBindingId::Help).footer_chord;
369 if chord.starts_with('/') {
370 return chord.to_string();
371 }
372 format!(
373 "{} {}",
374 chord,
375 codewhale_localization::tr(locale, codewhale_localization::MessageId::InfoLineHelp)
376 )
377 }
378
379 #[must_use]
380 pub fn binding(id: ShellBindingId) -> &'static ShellBinding {
381 SHELL_BINDINGS
382 .iter()
383 .find(|binding| binding.id == id)
384 .expect("shell binding catalog is exhaustive")
385 }
386
387 /// Platform-aware chord for opening complete tool or approval details.
388 #[must_use]
389 pub fn tool_details_chord() -> Cow<'static, str> {
390 display_chord(binding(ShellBindingId::ToolDetails).footer_chord)
391 }
392
393 /// Render a portable `Alt+X` chord for the current platform. macOS normally
394 /// shows `⌥X`; ASCII-safe terminals retain the portable `Alt+X` spelling.
395 #[must_use]
396 pub fn display_chord(chord: &'static str) -> Cow<'static, str> {
397 display_chord_for_platform_and_ascii(
398 chord,
399 cfg!(target_os = "macos"),
400 crate::tui::color_compat::ascii_safe_enabled(),
401 )
402 }
403
404 #[cfg(test)]
405 #[must_use]
406 pub fn display_chord_for_platform(chord: &'static str, is_macos: bool) -> Cow<'static, str> {
407 display_chord_for_platform_and_ascii(chord, is_macos, false)
408 }
409
410 fn display_chord_for_platform_and_ascii(
411 chord: &'static str,
412 is_macos: bool,
413 ascii_safe: bool,
414 ) -> Cow<'static, str> {
415 if ascii_safe {
416 return Cow::Borrowed(chord);
417 }
418 if !is_macos {
419 return Cow::Borrowed(chord);
420 }
421 let rendered = chord.replace("Alt+", "⌥").replace("F1", "fn+F1");
422 if rendered == chord {
423 Cow::Borrowed(chord)
424 } else {
425 Cow::Owned(rendered)
426 }
427 }
428
429 /// Details/output opens only on Option+V (macOS legacy `√`) or Alt+V.
430 /// Bare `v` always types `v` — never a shortcut, in any focus state.
431 #[must_use]
432 pub fn is_tool_details_shortcut(key: &KeyEvent) -> bool {
433 if key_shortcuts::is_macos_option_v_legacy_key(key) {
434 return true;
435 }
436 matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'))
437 && key_shortcuts::alt_nav_modifiers(key.modifiers)
438 }
439
440 #[must_use]
441 pub fn is_context_inspector_shortcut(key: &KeyEvent) -> bool {
442 matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
443 && key_shortcuts::alt_nav_modifiers(key.modifiers)
444 }
445
446 /// Route entry stays on a non-printable function key so it never steals a
447 /// model/provider name from the composer. `/provider` remains available in
448 /// terminals that do not forward function keys.
449 #[must_use]
450 pub fn is_provider_route_shortcut(key: &KeyEvent) -> bool {
451 matches!(key.code, KeyCode::F(3)) && key.modifiers.is_empty()
452 }
453
454 #[must_use]
455 pub fn is_help_shortcut(key: &KeyEvent) -> bool {
456 if matches!(key.code, KeyCode::F(1)) {
457 return true;
458 }
459 // Windows delivers AltGr as Ctrl+Alt, so a layout-emitted glyph (e.g.
460 // AltGr+Q typing '/' on ABNT2) would satisfy a bare CONTROL check.
461 // AltGr chords are text, never shortcuts (#4723).
462 let altgr = crate::tui::widgets::key_hint::is_altgr(key.modifiers);
463 if matches!(key.code, KeyCode::Char('/'))
464 && key.modifiers.contains(KeyModifiers::CONTROL)
465 && !altgr
466 {
467 return true;
468 }
469 // Some legacy terminal stacks encode Ctrl+/ as the ASCII unit separator,
470 // which crossterm reports as Ctrl+7 or Ctrl+_. Accept both portable
471 // decodings so the documented fallback remains real.
472 if matches!(key.code, KeyCode::Char('7') | KeyCode::Char('_'))
473 && key.modifiers.contains(KeyModifiers::CONTROL)
474 && !altgr
475 {
476 return true;
477 }
478 // Alt+? still opens help where the terminal delivers it, but it is not
479 // advertised anywhere (TUI-DOG-003).
480 matches!(key.code, KeyCode::Char('?')) && key_shortcuts::alt_nav_modifiers(key.modifiers)
481 }
482
483 #[must_use]
484 pub fn is_settings_shortcut(key: &KeyEvent) -> bool {
485 matches!(key.code, KeyCode::F(2)) && key.modifiers.is_empty()
486 }
487
488 /// Tab cycles the session mode. Terminal chords that mean something else to
489 /// the host (Ctrl/Alt/Cmd+Tab) are not ours, and Shift+Tab is the permission
490 /// cycle below.
491 #[must_use]
492 pub fn is_mode_cycle_shortcut(key: &KeyEvent) -> bool {
493 matches!(key.code, KeyCode::Tab)
494 && !key.modifiers.intersects(
495 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::SHIFT,
496 )
497 }
498
499 /// Shift+Tab cycles the permission posture. Terminals encode the same chord
500 /// either as `BackTab` or as `Tab` + SHIFT; accept both.
501 #[must_use]
502 pub fn is_permission_cycle_shortcut(key: &KeyEvent) -> bool {
503 let forbidden = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
504 if key.modifiers.intersects(forbidden) {
505 return false;
506 }
507 matches!(key.code, KeyCode::BackTab)
508 || (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
509 }
510
511 /// Ctrl+Tab (kitty protocol: `Tab` + CONTROL) or Ctrl+] cycles the bottom
512 /// dock view forward. Legacy terminals send Ctrl+] as ASCII 0x1d, which
513 /// crossterm decodes as Ctrl+5. AltGr chords stay text (#4723).
514 #[must_use]
515 pub fn is_view_cycle_shortcut(key: &KeyEvent) -> bool {
516 if crate::tui::widgets::key_hint::is_altgr(key.modifiers) {
517 return false;
518 }
519 let ctrl_only = key.modifiers.contains(KeyModifiers::CONTROL)
520 && !key
521 .modifiers
522 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::SHIFT);
523 ctrl_only && matches!(key.code, KeyCode::Tab | KeyCode::Char(']' | '5'))
524 }
525
526 /// Ctrl+Shift+Tab (kitty protocol: `BackTab` or `Tab` with CONTROL|SHIFT)
527 /// cycles the bottom dock view backward.
528 #[must_use]
529 pub fn is_view_cycle_back_shortcut(key: &KeyEvent) -> bool {
530 if key
531 .modifiers
532 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
533 || !key.modifiers.contains(KeyModifiers::CONTROL)
534 {
535 return false;
536 }
537 matches!(key.code, KeyCode::BackTab)
538 || (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
539 }
540
541 #[cfg(test)]
542 mod tests {
543 use super::*;
544
545 #[test]
546 fn view_cycle_chords_are_ctrl_tab_and_ctrl_bracket() {
547 let ctrl_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL);
548 let ctrl_bracket = KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL);
549 let ctrl_shift_tab = KeyEvent::new(
550 KeyCode::BackTab,
551 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
552 );
553 assert_eq!(
554 route(Focus::Composer, &ctrl_tab),
555 Some(ShellBindingId::ViewCycle)
556 );
557 assert_eq!(
558 route(Focus::Panel, &ctrl_bracket),
559 Some(ShellBindingId::ViewCycle)
560 );
561 assert_eq!(
562 route(Focus::Composer, &ctrl_shift_tab),
563 Some(ShellBindingId::ViewCycleBack)
564 );
565 // Plain Tab / Shift+Tab stay the mode and permission cycles.
566 assert_eq!(
567 route(
568 Focus::Composer,
569 &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)
570 ),
571 Some(ShellBindingId::ModeCycle)
572 );
573 assert_eq!(
574 route(
575 Focus::Composer,
576 &KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)
577 ),
578 Some(ShellBindingId::PermissionCycle)
579 );
580 // Ctrl+T is reasoning effort, not ours; a bare `]` is composer text.
581 assert_eq!(
582 route(
583 Focus::Composer,
584 &KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)
585 ),
586 None
587 );
588 assert_eq!(
589 route(
590 Focus::Composer,
591 &KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)
592 ),
593 None
594 );
595 // Modals keep every key.
596 assert_eq!(route(Focus::Modal(ModalKind::Pager), &ctrl_bracket), None);
597 }
598
599 #[test]
600 fn work_bar_accepts_legacy_and_enhanced_keys_on_launch() {
601 for code in [KeyCode::Tab, KeyCode::Char(']'), KeyCode::Char('5')] {
602 let key = KeyEvent::new(code, KeyModifiers::CONTROL);
603 assert_eq!(route(Focus::Launch, &key), Some(ShellBindingId::ViewCycle));
604 assert_eq!(route(Focus::Onboarding, &key), None);
605 assert_eq!(route(Focus::Modal(ModalKind::Pager), &key), None);
606 }
607 for modifiers in [
608 KeyModifiers::NONE,
609 KeyModifiers::ALT,
610 KeyModifiers::CONTROL | KeyModifiers::ALT,
611 ] {
612 assert!(!is_view_cycle_shortcut(&KeyEvent::new(
613 KeyCode::Char('5'),
614 modifiers
615 )));
616 }
617 let backwards = KeyEvent::new(
618 KeyCode::BackTab,
619 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
620 );
621 assert_eq!(
622 route(Focus::Launch, &backwards),
623 Some(ShellBindingId::ViewCycleBack)
624 );
625 }
626
627 #[test]
628 fn bare_v_is_never_a_shortcut_in_any_state() {
629 // TUI-DOG-002: bare `v` always types `v`; there is no focus state in
630 // which it opens details, so the matcher takes no focus argument.
631 let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
632 assert!(!is_tool_details_shortcut(&plain_v));
633 let plain_upper_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT);
634 assert!(!is_tool_details_shortcut(&plain_upper_v));
635 }
636
637 #[test]
638 fn alt_v_and_macos_option_v_open_details() {
639 let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
640 assert!(is_tool_details_shortcut(&alt_v));
641 let alt_upper_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::ALT);
642 assert!(is_tool_details_shortcut(&alt_upper_v));
643 }
644
645 #[test]
646 fn details_label_is_option_glyph_on_macos_and_alt_elsewhere() {
647 assert_eq!(display_chord_for_platform("Alt+V", true), "⌥V");
648 assert_eq!(display_chord_for_platform("Alt+V", false), "Alt+V");
649 }
650
651 #[test]
652 fn chrome_never_advertises_a_key_terminals_eat() {
653 // F1 stays in the catalog (it works where delivered) but no chrome
654 // hint may print it; the help hint is derived from the binding.
655 // Ctrl+/ is still accepted, but chrome advertises the route that
656 // works in every terminal.
657 assert_eq!(binding(ShellBindingId::Help).footer_chord, "/help");
658 let hint = info_help_hint(codewhale_localization::Locale::En);
659 assert_eq!(hint, "/help", "a slash command names itself");
660 assert!(
661 is_help_shortcut(&KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL)),
662 "Ctrl+/ must keep working for the terminals that deliver it"
663 );
664 for binding in SHELL_BINDINGS {
665 assert!(!binding.footer_chord.contains("F1"), "{:?}", binding.id);
666 assert!(!binding.footer_chord.contains("Alt+?"), "{:?}", binding.id);
667 }
668 }
669
670 #[test]
671 fn help_accepts_f1_ctrl_slash_and_unadvertised_fallbacks() {
672 assert!(is_help_shortcut(&KeyEvent::new(
673 KeyCode::F(1),
674 KeyModifiers::NONE
675 )));
676 assert!(is_help_shortcut(&KeyEvent::new(
677 KeyCode::Char('/'),
678 KeyModifiers::CONTROL
679 )));
680 assert!(is_help_shortcut(&KeyEvent::new(
681 KeyCode::Char('7'),
682 KeyModifiers::CONTROL
683 )));
684 assert!(is_help_shortcut(&KeyEvent::new(
685 KeyCode::Char('_'),
686 KeyModifiers::CONTROL
687 )));
688 // Unadvertised but accepted where the terminal delivers them.
689 assert!(is_help_shortcut(&KeyEvent::new(
690 KeyCode::Char('?'),
691 KeyModifiers::ALT
692 )));
693 let inverted_question = KeyEvent::new(KeyCode::Char('\u{00bf}'), KeyModifiers::NONE);
694 assert!(!is_help_shortcut(&inverted_question));
695 }
696
697 #[test]
698 fn altgr_slash_types_text_instead_of_opening_help() {
699 // Windows encodes AltGr as Ctrl+Alt: AltGr+Q on ABNT2 delivers '/'
700 // with CONTROL|ALT and must reach the composer as text (#4723).
701 let altgr_slash = KeyEvent::new(
702 KeyCode::Char('/'),
703 KeyModifiers::CONTROL | KeyModifiers::ALT,
704 );
705 let altgr_seven = KeyEvent::new(
706 KeyCode::Char('7'),
707 KeyModifiers::CONTROL | KeyModifiers::ALT,
708 );
709 if cfg!(windows) {
710 assert!(!is_help_shortcut(&altgr_slash));
711 assert!(!is_help_shortcut(&altgr_seven));
712 } else {
713 // Elsewhere Ctrl+Alt is a deliberate chord and keeps working.
714 assert!(is_help_shortcut(&altgr_slash));
715 assert!(is_help_shortcut(&altgr_seven));
716 }
717 // Plain Ctrl+/ still opens help everywhere.
718 assert!(is_help_shortcut(&KeyEvent::new(
719 KeyCode::Char('/'),
720 KeyModifiers::CONTROL
721 )));
722 }
723
724 #[test]
725 fn settings_accepts_only_plain_f2() {
726 assert!(is_settings_shortcut(&KeyEvent::new(
727 KeyCode::F(2),
728 KeyModifiers::NONE
729 )));
730 assert!(!is_settings_shortcut(&KeyEvent::new(
731 KeyCode::F(2),
732 KeyModifiers::SHIFT
733 )));
734 assert!(!is_settings_shortcut(&KeyEvent::new(
735 KeyCode::F(1),
736 KeyModifiers::NONE
737 )));
738 }
739
740 #[test]
741 fn context_accepts_explicit_alt_c_without_stealing_layout_characters() {
742 let alt_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT);
743 assert!(is_context_inspector_shortcut(&alt_c));
744 let cedilla = KeyEvent::new(KeyCode::Char('\u{00e7}'), KeyModifiers::NONE);
745 assert!(!is_context_inspector_shortcut(&cedilla));
746 }
747
748 #[test]
749 fn infoline_route_f3_requires_a_plain_function_key() {
750 assert!(is_provider_route_shortcut(&KeyEvent::new(
751 KeyCode::F(3),
752 KeyModifiers::NONE
753 )));
754 assert!(!is_provider_route_shortcut(&KeyEvent::new(
755 KeyCode::F(3),
756 KeyModifiers::ALT
757 )));
758 assert!(!is_provider_route_shortcut(&KeyEvent::new(
759 KeyCode::Char('3'),
760 KeyModifiers::NONE
761 )));
762 }
763
764 #[test]
765 fn chrome_only_advertises_chords_that_are_live_where_it_is_shown() {
766 // The metrics line's help hint is built from this table, so it must
767 // not be able to name a chord the same table refuses at any focus
768 // the chrome is rendered in (the hint paints on every screen).
769 let hint = info_help_hint(codewhale_localization::Locale::En);
770 let help = binding(ShellBindingId::Help);
771 assert!(hint.starts_with(help.footer_chord), "{hint}");
772 assert_eq!(help.focus, FocusScope::Everywhere);
773 assert!(help.matches(&KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL)));
774 }
775
776 #[test]
777 fn focus_scopes_nest_from_the_session_outwards() {
778 let ladder = [
779 FocusScope::SessionShell,
780 FocusScope::AnyShell,
781 FocusScope::AnyShellOrConfig,
782 FocusScope::Everywhere,
783 ];
784 let states = [
785 Focus::RedactionGate,
786 Focus::Composer,
787 Focus::Panel,
788 Focus::Launch,
789 Focus::Modal(ModalKind::Config),
790 Focus::Modal(ModalKind::Approval),
791 Focus::Onboarding,
792 ];
793 for pair in ladder.windows(2) {
794 for focus in states {
795 assert!(
796 !pair[0].admits(focus) || pair[1].admits(focus),
797 "{:?} admits {focus:?} but the wider {:?} does not",
798 pair[0],
799 pair[1]
800 );
801 }
802 }
803 // Nothing but Help/Settings may reach a focused workflow.
804 for binding in SHELL_BINDINGS {
805 assert_eq!(
806 binding.focus.admits(Focus::Modal(ModalKind::Approval)),
807 binding.focus == FocusScope::Everywhere,
808 "{:?} must not reach across an approval decision",
809 binding.id
810 );
811 }
812 }
813
814 #[test]
815 fn tab_and_shift_tab_are_distinct_bindings() {
816 let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
817 let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
818 assert_eq!(
819 route(Focus::Composer, &tab),
820 Some(ShellBindingId::ModeCycle)
821 );
822 assert_eq!(
823 route(Focus::Composer, &shift_tab),
824 Some(ShellBindingId::PermissionCycle)
825 );
826 // Both shell controls are live on the launch stage as well.
827 assert_eq!(route(Focus::Launch, &tab), Some(ShellBindingId::ModeCycle));
828 assert_eq!(
829 route(Focus::Launch, &shift_tab),
830 Some(ShellBindingId::PermissionCycle)
831 );
832 }
833
834 #[test]
835 fn redaction_choices_require_the_gate_and_explicit_unmodified_keys() {
836 for (id, keys) in [
837 (ShellBindingId::RedactionGateConfirm, ['1', 'y', 'Y']),
838 (ShellBindingId::RedactionGateKeepOrBack, ['2', 'u', 'U']),
839 (ShellBindingId::RedactionGateQuit, ['3', 'n', 'N']),
840 ] {
841 for key in keys {
842 for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
843 let event = KeyEvent::new(KeyCode::Char(key), modifiers);
844 assert_eq!(route(Focus::RedactionGate, &event), Some(id));
845 for focus in [
846 Focus::Composer,
847 Focus::Panel,
848 Focus::Launch,
849 Focus::Onboarding,
850 Focus::Modal(ModalKind::Approval),
851 ] {
852 assert_eq!(route(focus, &event), None, "{focus:?}: {event:?}");
853 }
854 }
855 for modifiers in [
856 KeyModifiers::CONTROL,
857 KeyModifiers::ALT,
858 KeyModifiers::SUPER,
859 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
860 ] {
861 assert_eq!(
862 route(
863 Focus::RedactionGate,
864 &KeyEvent::new(KeyCode::Char(key), modifiers)
865 ),
866 None
867 );
868 }
869 }
870 }
871 for code in [KeyCode::Enter, KeyCode::F(1), KeyCode::F(2), KeyCode::Tab] {
872 assert_eq!(
873 route(
874 Focus::RedactionGate,
875 &KeyEvent::new(code, KeyModifiers::NONE)
876 ),
877 None
878 );
879 }
880 for code in [
881 KeyCode::Up,
882 KeyCode::Down,
883 KeyCode::PageUp,
884 KeyCode::PageDown,
885 ] {
886 assert_eq!(
887 route(
888 Focus::RedactionGate,
889 &KeyEvent::new(code, KeyModifiers::NONE)
890 ),
891 Some(ShellBindingId::RedactionGateScroll)
892 );
893 }
894 }
895
896 #[test]
897 fn catalog_chords_match_final_contract() {
898 assert_eq!(binding(ShellBindingId::Help).catalog_chord, "F1 / Ctrl+/");
899 assert_eq!(
900 binding(ShellBindingId::ContextInspector).catalog_chord,
901 "/context"
902 );
903 assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V");
904 assert_eq!(
905 binding(ShellBindingId::ProviderRoute).catalog_chord,
906 "F3 / /provider"
907 );
908 for binding in SHELL_BINDINGS {
909 assert!(!binding.catalog_chord.contains("Alt+?"));
910 assert_ne!(binding.catalog_chord, "v");
911 assert!(!binding.catalog_chord.starts_with("v /"));
912 assert!(!binding.footer_chord.contains("Alt+?"));
913 assert_ne!(binding.footer_chord, "v");
914 }
915 }
916 }
917
917 lines RUST