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