| 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(¸la)); |
| 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 |