返回 CodeWhale
keybindings.rs
根目录 / crates / tui / src / tui / keybindings.rs
1 //! Documentation-only catalog of every user-facing keybinding.
2 //!
3 //! This module is the *single source of truth* for what shortcuts the help
4 //! overlay renders. The actual key handlers live in `tui/ui.rs` (and a few
5 //! sibling modules); they read keys directly off the crossterm event stream
6 //! and intentionally do **not** consult this catalog. The catalog exists so
7 //! that:
8 //!
9 //! 1. The help overlay (`tui/views/help.rs`) does not have to maintain a
10 //! parallel list that silently rots when a handler is added or moved.
11 //! 2. New contributors have one place to look when answering "which keys are
12 //! bound, and where do they go?"
13 //!
14 //! When you add or change a binding in `ui.rs`, **add or update the matching
15 //! entry here**. The compile-only side-effect of forgetting is a stale help
16 //! screen; there is no runtime crash, so the discipline lives in code review.
17 //!
18 //! Entries are grouped by `KeybindingSection`. The `chord` field is a
19 //! human-readable string formatted exactly the way it should appear in help —
20 //! we avoid storing `KeyBinding` values directly because many shortcuts are
21 //! pairs (`↑/↓`) or families (`1-8`) that don't map cleanly to a single
22 //! chord.
23
24 use std::borrow::Cow;
25
26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 pub enum KeybindingSection {
28 Navigation,
29 Editing,
30 Submission,
31 Modes,
32 Sessions,
33 Clipboard,
34 Pointer,
35 Help,
36 }
37
38 impl KeybindingSection {
39 /// Every section, in declaration order. Callers that need to walk the set —
40 /// the help overlay's fold defaults, its rank lookup — read this instead of
41 /// restating the list, which is how `Pointer` came to be missing from one
42 /// of them and not the other.
43 pub const ALL: [Self; 8] = [
44 Self::Navigation,
45 Self::Editing,
46 Self::Submission,
47 Self::Modes,
48 Self::Sessions,
49 Self::Clipboard,
50 Self::Pointer,
51 Self::Help,
52 ];
53
54 pub fn label(self, locale: codewhale_localization::Locale) -> Cow<'static, str> {
55 use codewhale_localization::{MessageId, tr};
56 let id = match self {
57 Self::Navigation => MessageId::HelpSectionNavigation,
58 Self::Editing => MessageId::HelpSectionEditing,
59 Self::Submission => MessageId::HelpSectionActions,
60 Self::Modes => MessageId::HelpSectionModes,
61 Self::Sessions => MessageId::HelpSectionSessions,
62 Self::Clipboard => MessageId::HelpSectionClipboard,
63 Self::Pointer => MessageId::HelpSectionPointer,
64 Self::Help => MessageId::HelpSectionHelp,
65 };
66 tr(locale, id)
67 }
68
69 /// Stable ordering for help rendering — matches the variant declaration
70 /// order; explicit so adding a section forces a deliberate placement.
71 pub fn rank(self) -> u8 {
72 match self {
73 Self::Navigation => 0,
74 Self::Editing => 1,
75 Self::Submission => 2,
76 Self::Modes => 3,
77 Self::Sessions => 4,
78 Self::Clipboard => 5,
79 Self::Pointer => 6,
80 Self::Help => 7,
81 }
82 }
83 }
84
85 #[derive(Debug, Clone, Copy)]
86 pub struct KeybindingEntry {
87 pub chord: &'static str,
88 pub description_id: codewhale_localization::MessageId,
89 pub section: KeybindingSection,
90 }
91
92 /// Canonical list of keybindings shown in the help overlay.
93 ///
94 /// Strings are written in the same notation the existing help screen uses so
95 /// readers can cross-reference with documentation: `Ctrl+X`, `Alt+X`,
96 /// `Shift+X`, `↑/↓`, `PgUp/PgDn`, etc. Help renderers may apply per-platform
97 /// substitutions (e.g. `⌥` for Alt on macOS) at render time, but the catalog
98 /// itself stores the portable form.
99 pub const KEYBINDINGS: &[KeybindingEntry] = &[
100 // --- Navigation ---
101 KeybindingEntry {
102 chord: "↑ / ↓",
103 description_id: codewhale_localization::MessageId::KbScrollTranscript,
104 section: KeybindingSection::Navigation,
105 },
106 KeybindingEntry {
107 chord: "Alt+↑ / Alt+↓",
108 description_id: codewhale_localization::MessageId::KbScrollTranscriptAlt,
109 section: KeybindingSection::Navigation,
110 },
111 KeybindingEntry {
112 chord: "Shift+↑ / Shift+↓",
113 description_id: codewhale_localization::MessageId::KbBrowseHistory,
114 section: KeybindingSection::Navigation,
115 },
116 KeybindingEntry {
117 chord: "PgUp / PgDn",
118 description_id: codewhale_localization::MessageId::KbScrollPage,
119 section: KeybindingSection::Navigation,
120 },
121 KeybindingEntry {
122 chord: "Ctrl+Home / Ctrl+End",
123 description_id: codewhale_localization::MessageId::KbJumpTopBottom,
124 section: KeybindingSection::Navigation,
125 },
126 KeybindingEntry {
127 chord: "Alt+G / Alt+Shift+G",
128 description_id: codewhale_localization::MessageId::KbJumpTopBottomEmpty,
129 section: KeybindingSection::Navigation,
130 },
131 KeybindingEntry {
132 chord: "Alt+[ / Alt+]",
133 description_id: codewhale_localization::MessageId::KbJumpToolBlocks,
134 section: KeybindingSection::Navigation,
135 },
136 // --- Editing ---
137 KeybindingEntry {
138 chord: "← / → / Ctrl+←/→ / Alt+←/→",
139 description_id: codewhale_localization::MessageId::KbMoveCursor,
140 section: KeybindingSection::Editing,
141 },
142 KeybindingEntry {
143 chord: "Home / End",
144 description_id: codewhale_localization::MessageId::KbJumpLineStartEnd,
145 section: KeybindingSection::Editing,
146 },
147 KeybindingEntry {
148 chord: "Ctrl+A / Ctrl+E",
149 description_id: codewhale_localization::MessageId::KbJumpLineStartEnd,
150 section: KeybindingSection::Editing,
151 },
152 KeybindingEntry {
153 chord: "Backspace / Delete",
154 description_id: codewhale_localization::MessageId::KbDeleteChar,
155 section: KeybindingSection::Editing,
156 },
157 KeybindingEntry {
158 chord: "Ctrl+W / Ctrl+Backspace / Alt+Backspace",
159 description_id: codewhale_localization::MessageId::KbDeleteWord,
160 section: KeybindingSection::Editing,
161 },
162 KeybindingEntry {
163 chord: "Ctrl+Y",
164 description_id: codewhale_localization::MessageId::KbYank,
165 section: KeybindingSection::Editing,
166 },
167 KeybindingEntry {
168 chord: "Ctrl+Shift+E / Cmd+Shift+E",
169 description_id: codewhale_localization::MessageId::KbToggleFileTree,
170 section: KeybindingSection::Navigation,
171 },
172 KeybindingEntry {
173 chord: "Shift+←/→ / Shift+Home/End / Ctrl+Shift+←/→ / Alt+Shift+←/→ / Ctrl+Shift+Home/End",
174 description_id: codewhale_localization::MessageId::KbSelectText,
175 section: KeybindingSection::Editing,
176 },
177 KeybindingEntry {
178 // Ctrl+A keeps its readline meaning (start of input); select-all is
179 // the shifted chord, plus native Cmd+A on terminals that forward Cmd.
180 chord: "Ctrl+Shift+A / Cmd+A",
181 description_id: codewhale_localization::MessageId::KbSelectAllDraft,
182 section: KeybindingSection::Editing,
183 },
184 KeybindingEntry {
185 chord: "Ctrl+U",
186 description_id: codewhale_localization::MessageId::KbClearDraft,
187 section: KeybindingSection::Editing,
188 },
189 KeybindingEntry {
190 chord: "Ctrl+Z",
191 description_id: codewhale_localization::MessageId::KbRestoreClearedDraft,
192 section: KeybindingSection::Editing,
193 },
194 KeybindingEntry {
195 chord: "Ctrl+G / Ctrl+S",
196 description_id: codewhale_localization::MessageId::KbStashDraft,
197 section: KeybindingSection::Editing,
198 },
199 KeybindingEntry {
200 chord: "Alt+R",
201 description_id: codewhale_localization::MessageId::KbSearchHistory,
202 section: KeybindingSection::Editing,
203 },
204 KeybindingEntry {
205 // Ctrl+J leads because it is a plain control byte every terminal
206 // sends. Shift+Enter only arrives where the kitty keyboard protocol
207 // is live — see `composer_ui::terminal_can_report_shift_enter` — so
208 // it is listed last rather than taught first.
209 chord: "Ctrl+J / Alt+Enter / Shift+Enter (enhanced terminals)",
210 description_id: codewhale_localization::MessageId::KbInsertNewline,
211 section: KeybindingSection::Editing,
212 },
213 // --- Submission / actions ---
214 KeybindingEntry {
215 chord: "Enter",
216 description_id: codewhale_localization::MessageId::KbSendDraft,
217 section: KeybindingSection::Submission,
218 },
219 KeybindingEntry {
220 chord: "Esc",
221 description_id: codewhale_localization::MessageId::KbCloseMenu,
222 section: KeybindingSection::Submission,
223 },
224 KeybindingEntry {
225 chord: "Ctrl+C",
226 description_id: codewhale_localization::MessageId::KbCancelOrExit,
227 section: KeybindingSection::Submission,
228 },
229 KeybindingEntry {
230 chord: "Ctrl+B",
231 description_id: codewhale_localization::MessageId::KbShellControls,
232 section: KeybindingSection::Submission,
233 },
234 KeybindingEntry {
235 chord: "Ctrl+D",
236 description_id: codewhale_localization::MessageId::KbExitEmpty,
237 section: KeybindingSection::Submission,
238 },
239 KeybindingEntry {
240 chord: "Ctrl+K",
241 description_id: codewhale_localization::MessageId::KbCommandPalette,
242 section: KeybindingSection::Submission,
243 },
244 KeybindingEntry {
245 chord: "F2",
246 description_id: codewhale_localization::MessageId::KbSettings,
247 section: KeybindingSection::Submission,
248 },
249 KeybindingEntry {
250 chord: "Ctrl+X (Activity workbar)",
251 description_id: codewhale_localization::MessageId::KbCancelBackgroundShellJobs,
252 section: KeybindingSection::Submission,
253 },
254 KeybindingEntry {
255 chord: "Ctrl+P",
256 description_id: codewhale_localization::MessageId::KbFuzzyFilePicker,
257 section: KeybindingSection::Submission,
258 },
259 KeybindingEntry {
260 // `/context` is the guaranteed path; Alt+C is an unadvertised
261 // handler until proven in real terminals (TUI-DOG-003).
262 chord: "/context",
263 description_id: codewhale_localization::MessageId::KbCompactInspector,
264 section: KeybindingSection::Submission,
265 },
266 KeybindingEntry {
267 // `/provider` remains the portable command route; F3 mirrors the
268 // clickable topbar route segment without consuming composer text.
269 chord: "F3 / /provider",
270 description_id: codewhale_localization::MessageId::CmdProviderDescription,
271 section: KeybindingSection::Submission,
272 },
273 KeybindingEntry {
274 chord: "Alt+L",
275 description_id: codewhale_localization::MessageId::KbLastMessagePager,
276 section: KeybindingSection::Submission,
277 },
278 KeybindingEntry {
279 // Bare `v` always types `v`; details is Alt+V only (⌥V on macOS).
280 chord: "Alt+V",
281 description_id: codewhale_localization::MessageId::KbSelectedDetails,
282 section: KeybindingSection::Submission,
283 },
284 KeybindingEntry {
285 chord: "Ctrl+O",
286 description_id: codewhale_localization::MessageId::KbReasoningDetail,
287 section: KeybindingSection::Submission,
288 },
289 KeybindingEntry {
290 chord: "Ctrl+Alt+O",
291 description_id: codewhale_localization::MessageId::KbTurnInspector,
292 section: KeybindingSection::Submission,
293 },
294 KeybindingEntry {
295 chord: "Ctrl+Shift+O / F4",
296 description_id: codewhale_localization::MessageId::KbExternalEditor,
297 section: KeybindingSection::Editing,
298 },
299 KeybindingEntry {
300 // `/transcript` is the reliable fallback when a terminal cannot
301 // distinguish Ctrl+Shift+T from Ctrl+T.
302 chord: "/transcript / Ctrl+Shift+T",
303 description_id: codewhale_localization::MessageId::KbLiveTranscript,
304 section: KeybindingSection::Submission,
305 },
306 KeybindingEntry {
307 chord: "Ctrl+T",
308 description_id: codewhale_localization::MessageId::KbCycleThinking,
309 section: KeybindingSection::Modes,
310 },
311 KeybindingEntry {
312 chord: "Esc Esc",
313 description_id: codewhale_localization::MessageId::KbBacktrackMessage,
314 section: KeybindingSection::Submission,
315 },
316 // --- Modes ---
317 KeybindingEntry {
318 chord: "Tab",
319 description_id: codewhale_localization::MessageId::KbCompleteCycleModes,
320 section: KeybindingSection::Modes,
321 },
322 KeybindingEntry {
323 chord: "Shift+Tab",
324 description_id: codewhale_localization::MessageId::KbCyclePermissions,
325 section: KeybindingSection::Modes,
326 },
327 KeybindingEntry {
328 chord: "Alt+1-8",
329 description_id: codewhale_localization::MessageId::KbJumpPlanAgentYolo,
330 section: KeybindingSection::Modes,
331 },
332 KeybindingEntry {
333 chord: "Alt+P / Alt+A / Alt+Y",
334 description_id: codewhale_localization::MessageId::KbAltJumpPlanAgentYolo,
335 section: KeybindingSection::Modes,
336 },
337 KeybindingEntry {
338 chord: "Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+0 / Ctrl+Alt+0",
339 description_id: codewhale_localization::MessageId::KbFocusSidebar,
340 section: KeybindingSection::Modes,
341 },
342 // --- Sessions ---
343 KeybindingEntry {
344 chord: "Ctrl+R",
345 description_id: codewhale_localization::MessageId::KbSessionPicker,
346 section: KeybindingSection::Sessions,
347 },
348 KeybindingEntry {
349 chord: "Ctrl+L",
350 description_id: codewhale_localization::MessageId::KbCompactContext,
351 section: KeybindingSection::Sessions,
352 },
353 KeybindingEntry {
354 // Same shifted-Ctrl family as Ctrl+Shift+A/E/O; routes through the
355 // `/update install` command, so managed installs keep their gate.
356 chord: "Ctrl+Shift+U",
357 description_id: codewhale_localization::MessageId::KbUpdateInstall,
358 section: KeybindingSection::Sessions,
359 },
360 // --- Clipboard ---
361 KeybindingEntry {
362 // Keep both terminal-client families visible: the TUI may be running
363 // on Linux while the user's SSH terminal is on macOS (or vice versa).
364 chord: "Cmd+V / Ctrl+Shift+V",
365 description_id: codewhale_localization::MessageId::KbTerminalPaste,
366 section: KeybindingSection::Clipboard,
367 },
368 KeybindingEntry {
369 chord: "Ctrl+V",
370 description_id: codewhale_localization::MessageId::KbPasteAttach,
371 section: KeybindingSection::Clipboard,
372 },
373 KeybindingEntry {
374 // Terminal-native copy chords are normally consumed by the local
375 // terminal and never become Codewhale key events. Ctrl+C is the
376 // reliable in-app copy path when a Codewhale selection is active.
377 chord: "Ctrl+C (selection)",
378 description_id: codewhale_localization::MessageId::KbCopySelection,
379 section: KeybindingSection::Clipboard,
380 },
381 KeybindingEntry {
382 chord: "@path",
383 description_id: codewhale_localization::MessageId::KbAttachPath,
384 section: KeybindingSection::Clipboard,
385 },
386 // --- Pointer ---
387 // The mouse is a first-class input here: every one of these is wired in
388 // `tui/mouse_ui.rs`. A pointer that does nothing where a user expects it
389 // to act reads as a broken app, so what responds is documented.
390 KeybindingEntry {
391 chord: "Wheel up / down",
392 description_id: codewhale_localization::MessageId::KbPointerScroll,
393 section: KeybindingSection::Pointer,
394 },
395 KeybindingEntry {
396 chord: "Click",
397 description_id: codewhale_localization::MessageId::KbPointerClick,
398 section: KeybindingSection::Pointer,
399 },
400 KeybindingEntry {
401 chord: "Drag",
402 description_id: codewhale_localization::MessageId::KbPointerDrag,
403 section: KeybindingSection::Pointer,
404 },
405 KeybindingEntry {
406 chord: "Right click",
407 description_id: codewhale_localization::MessageId::KbContextMenu,
408 section: KeybindingSection::Pointer,
409 },
410 // --- Help ---
411 KeybindingEntry {
412 // `/help` leads because it is the one route that works in every
413 // terminal. F1 is eaten by tmux and several emulators, and how a
414 // terminal encodes Ctrl+/ varies; both still open help where they
415 // arrive. Alt+? stays an unadvertised handler (TUI-DOG-003).
416 chord: "/help / F1 / Ctrl+/",
417 description_id: codewhale_localization::MessageId::KbHelpOverlay,
418 section: KeybindingSection::Help,
419 },
420 KeybindingEntry {
421 // The work dock was reachable but never listed here, so the only way
422 // to learn the chord was the footer's one-time hint.
423 chord: "Ctrl+Tab / Ctrl+]",
424 description_id: codewhale_localization::MessageId::KbCycleWorkDock,
425 section: KeybindingSection::Navigation,
426 },
427 KeybindingEntry {
428 chord: "Ctrl+Shift+Tab",
429 description_id: codewhale_localization::MessageId::KbCycleWorkDockBack,
430 section: KeybindingSection::Navigation,
431 },
432 ];
433
434 #[cfg(test)]
435 mod tests {
436 use super::*;
437
438 #[test]
439 fn catalog_is_non_empty_and_sections_have_entries() {
440 assert!(KEYBINDINGS.iter().any(|entry| !entry.chord.is_empty()));
441 // Every declared section should appear in the catalog at least once,
442 // otherwise the help overlay would render an empty heading.
443 let sections = [
444 KeybindingSection::Navigation,
445 KeybindingSection::Editing,
446 KeybindingSection::Submission,
447 KeybindingSection::Modes,
448 KeybindingSection::Sessions,
449 KeybindingSection::Clipboard,
450 KeybindingSection::Help,
451 ];
452 for section in sections {
453 assert!(
454 KEYBINDINGS.iter().any(|entry| entry.section == section),
455 "no entries for section {section:?}"
456 );
457 }
458 }
459
460 #[test]
461 fn help_advertises_f1_and_ctrl_slash_never_alt_question() {
462 // TUI-DOG-003: Alt+? is not advertised anywhere; F1 (with /help) is
463 // primary and Ctrl+/ is the secondary fallback.
464 assert!(
465 KEYBINDINGS.iter().any(|entry| {
466 entry.section == KeybindingSection::Help
467 && entry.chord.contains("F1")
468 && entry.chord.contains("Ctrl+/")
469 }),
470 "help must document F1 with the Ctrl+/ fallback"
471 );
472 assert!(
473 KEYBINDINGS
474 .iter()
475 .all(|entry| !entry.chord.contains("Alt+?")),
476 "Alt+? must not be advertised in the help catalog"
477 );
478 }
479
480 #[test]
481 fn composer_catalog_assigns_one_stable_role_to_each_chord() {
482 let chord_for = |id| {
483 KEYBINDINGS
484 .iter()
485 .find(|entry| entry.description_id == id)
486 .expect("composer binding should be documented")
487 .chord
488 };
489
490 assert_eq!(
491 chord_for(codewhale_localization::MessageId::KbInsertNewline),
492 "Ctrl+J / Alt+Enter / Shift+Enter (enhanced terminals)"
493 );
494 assert!(
495 KEYBINDINGS
496 .iter()
497 .all(|entry| !entry.chord.contains("Ctrl+Enter")
498 && !entry.chord.contains("Cmd+Enter"))
499 );
500 assert_eq!(
501 chord_for(codewhale_localization::MessageId::KbStashDraft),
502 "Ctrl+G / Ctrl+S"
503 );
504 assert_eq!(
505 chord_for(codewhale_localization::MessageId::KbSendDraft),
506 "Enter"
507 );
508
509 let tab_copy = codewhale_localization::tr(
510 codewhale_localization::Locale::En,
511 codewhale_localization::MessageId::KbCompleteCycleModes,
512 );
513 assert!(!tab_copy.to_ascii_lowercase().contains("queue"));
514 let stash_copy = codewhale_localization::tr(
515 codewhale_localization::Locale::En,
516 codewhale_localization::MessageId::KbStashDraft,
517 );
518 assert!(!stash_copy.to_ascii_lowercase().contains("send"));
519 }
520
521 #[test]
522 fn clipboard_help_distinguishes_terminal_text_graphical_image_and_in_app_copy() {
523 let terminal_paste = KEYBINDINGS
524 .iter()
525 .find(|entry| {
526 entry.description_id == codewhale_localization::MessageId::KbTerminalPaste
527 })
528 .expect("terminal paste binding should be documented");
529 let graphical_paste = KEYBINDINGS
530 .iter()
531 .find(|entry| entry.description_id == codewhale_localization::MessageId::KbPasteAttach)
532 .expect("graphical paste binding should be documented");
533 let copy = KEYBINDINGS
534 .iter()
535 .find(|entry| {
536 entry.description_id == codewhale_localization::MessageId::KbCopySelection
537 })
538 .expect("copy binding should be documented");
539
540 assert!(terminal_paste.chord.contains("Cmd+V"));
541 assert!(terminal_paste.chord.contains("Ctrl+Shift+V"));
542 assert_eq!(graphical_paste.chord, "Ctrl+V");
543 let terminal_description = codewhale_localization::tr(
544 codewhale_localization::Locale::En,
545 codewhale_localization::MessageId::KbTerminalPaste,
546 );
547 let graphical_description = codewhale_localization::tr(
548 codewhale_localization::Locale::En,
549 codewhale_localization::MessageId::KbPasteAttach,
550 );
551 assert!(!terminal_description.to_ascii_lowercase().contains("image"));
552 assert!(graphical_description.to_ascii_lowercase().contains("image"));
553 assert_eq!(copy.chord, "Ctrl+C (selection)");
554 assert!(!copy.chord.contains("Cmd+C"));
555 assert!(!copy.chord.contains("Ctrl+Shift+C"));
556 }
557
558 #[test]
559 fn transcript_navigation_catalog_does_not_advertise_bare_typing_keys() {
560 for stale in [
561 "g / G",
562 "[ / ]",
563 "l",
564 "?",
565 "Ctrl+↑ / Ctrl+↓",
566 "v",
567 "v / Alt+V",
568 ] {
569 assert!(
570 KEYBINDINGS.iter().all(|entry| entry.chord != stale),
571 "stale handler-free chord remains documented: {stale}"
572 );
573 }
574 for wired in ["Alt+G / Alt+Shift+G", "Alt+[ / Alt+]", "Alt+L", "Alt+V"] {
575 assert!(
576 KEYBINDINGS.iter().any(|entry| entry.chord == wired),
577 "wired transcript shortcut missing from help: {wired}"
578 );
579 }
580 }
581
582 #[test]
583 fn live_transcript_documents_command_before_shaky_chord() {
584 let transcript = KEYBINDINGS
585 .iter()
586 .find(|entry| {
587 entry.description_id == codewhale_localization::MessageId::KbLiveTranscript
588 })
589 .expect("live transcript entry should be documented");
590
591 assert_eq!(transcript.chord, "/transcript / Ctrl+Shift+T");
592 }
593
594 #[test]
595 fn shell_binding_source_matches_help_catalog_chords() {
596 use crate::tui::shell_key_routing::{ShellBindingId, binding};
597 assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V");
598 assert_eq!(
599 binding(ShellBindingId::ContextInspector).catalog_chord,
600 "/context"
601 );
602 assert_eq!(
603 binding(ShellBindingId::ProviderRoute).catalog_chord,
604 "F3 / /provider"
605 );
606 assert_eq!(binding(ShellBindingId::Help).catalog_chord, "F1 / Ctrl+/");
607 // Every ordinary shell binding: exclusive redaction choices are
608 // advertised only by their consent screen, never as global shortcuts.
609 // The two that were
610 // not covered had already drifted (the view-cycle chord read
611 // "Ctrl+] / Ctrl+Tab" here and "Ctrl+Tab / Ctrl+]" at the source),
612 // which is exactly the rot this module's doc comment warns about.
613 for id in [
614 ShellBindingId::ToolDetails,
615 ShellBindingId::ContextInspector,
616 ShellBindingId::ProviderRoute,
617 ShellBindingId::Help,
618 ShellBindingId::Settings,
619 ShellBindingId::ModeCycle,
620 ShellBindingId::PermissionCycle,
621 ShellBindingId::ViewCycle,
622 ShellBindingId::ViewCycleBack,
623 ] {
624 let chord = binding(id).catalog_chord;
625 assert!(
626 KEYBINDINGS
627 .iter()
628 .any(|entry| entry.chord == chord || entry.chord.contains(chord)),
629 "shell binding {id:?} chord missing from help catalog: {chord}"
630 );
631 }
632 }
633
634 #[test]
635 fn ctrl_o_and_ctrl_alt_o_help_copy_match_split_surfaces() {
636 let ctrl_o = KEYBINDINGS
637 .iter()
638 .find(|entry| entry.chord == "Ctrl+O")
639 .expect("Ctrl+O keybinding should be documented");
640
641 // Ctrl+O now opens the full recorded Reasoning Detail; the whole-turn
642 // Turn Inspector moved to Ctrl+Alt+O.
643 assert_eq!(
644 ctrl_o.description_id,
645 codewhale_localization::MessageId::KbReasoningDetail
646 );
647 assert_eq!(
648 codewhale_localization::tr(codewhale_localization::Locale::En, ctrl_o.description_id,),
649 "Open reasoning detail for the selected or current turn"
650 );
651
652 let ctrl_alt_o = KEYBINDINGS
653 .iter()
654 .find(|entry| entry.chord == "Ctrl+Alt+O")
655 .expect("Ctrl+Alt+O keybinding should be documented");
656 assert_eq!(
657 ctrl_alt_o.description_id,
658 codewhale_localization::MessageId::KbTurnInspector
659 );
660 assert_eq!(
661 codewhale_localization::tr(
662 codewhale_localization::Locale::En,
663 ctrl_alt_o.description_id,
664 ),
665 "Open Turn Inspector"
666 );
667
668 let editor = KEYBINDINGS
669 .iter()
670 .find(|entry| entry.chord == "Ctrl+Shift+O / F4")
671 .expect("external-editor keybinding should be documented");
672 assert_eq!(
673 codewhale_localization::tr(codewhale_localization::Locale::En, editor.description_id,),
674 "Edit the draft externally"
675 );
676 }
677
678 #[test]
679 fn ctrl_shift_u_update_install_is_documented_in_the_sessions_section() {
680 let entry = KEYBINDINGS
681 .iter()
682 .find(|entry| entry.chord == "Ctrl+Shift+U")
683 .expect("Ctrl+Shift+U keybinding should be documented");
684 assert_eq!(
685 entry.description_id,
686 codewhale_localization::MessageId::KbUpdateInstall
687 );
688 assert_eq!(entry.section, KeybindingSection::Sessions);
689 assert_eq!(
690 codewhale_localization::tr(codewhale_localization::Locale::En, entry.description_id),
691 "Check for and install the latest Codewhale update (`/update install`)"
692 );
693 }
694
695 #[test]
696 fn ctrl_x_activity_sidebar_cancel_all_is_documented() {
697 let ctrl_x_activity = KEYBINDINGS
698 .iter()
699 .find(|entry| entry.chord == "Ctrl+X (Activity workbar)")
700 .expect("Ctrl+X Activity workbar keybinding should be documented");
701
702 assert_eq!(
703 ctrl_x_activity.description_id,
704 codewhale_localization::MessageId::KbCancelBackgroundShellJobs
705 );
706 }
707
708 #[test]
709 fn tool_details_documents_alt_v_only_never_bare_v() {
710 let selected_details = KEYBINDINGS
711 .iter()
712 .filter(|entry| {
713 entry.description_id == codewhale_localization::MessageId::KbSelectedDetails
714 })
715 .map(|entry| entry.chord)
716 .collect::<Vec<_>>();
717
718 // TUI-DOG-002: bare `v` always types `v`; details is Alt+V only.
719 assert_eq!(selected_details, vec!["Alt+V"]);
720 assert!(
721 KEYBINDINGS
722 .iter()
723 .all(|entry| entry.chord != "v" && !entry.chord.starts_with("v /")),
724 "bare `v` must not be advertised — composer typing owns it"
725 );
726 }
727
728 /// #3758: a user who reads the help overlay must be able to answer "what
729 /// does this key do?" with one answer. A key may appear twice only when
730 /// every occurrence but one names its context in parentheses — the way
731 /// `Ctrl+C` and `Ctrl+C (selection)` do — so the reader is told which
732 /// reading applies. Two unqualified entries for the same key is the
733 /// ambiguity this guard exists to reject.
734 #[test]
735 fn every_advertised_key_names_exactly_one_canonical_action() {
736 struct Use {
737 chord: &'static str,
738 alternative: String,
739 description_id: codewhale_localization::MessageId,
740 }
741
742 let mut uses_by_key: std::collections::BTreeMap<String, Vec<Use>> =
743 std::collections::BTreeMap::new();
744 for entry in KEYBINDINGS {
745 for alternative in entry.chord.split(" / ") {
746 let alternative = alternative.trim();
747 // `Ctrl+C (selection)` → base key `Ctrl+C`, qualifier retained
748 // on the alternative so the check below can see it.
749 let base = alternative
750 .split_once(" (")
751 .map(|(head, _)| head)
752 .unwrap_or(alternative)
753 .trim()
754 .to_string();
755 uses_by_key.entry(base).or_default().push(Use {
756 chord: entry.chord,
757 alternative: alternative.to_string(),
758 description_id: entry.description_id,
759 });
760 }
761 }
762
763 for (key, uses) in &uses_by_key {
764 if uses.len() == 1 {
765 continue;
766 }
767 let single_action = uses
768 .iter()
769 .all(|entry| entry.description_id == uses[0].description_id);
770 if single_action {
771 // The same action documented from two spellings is fine —
772 // `Home / End` and `Ctrl+A / Ctrl+E` both jump to line edges.
773 continue;
774 }
775 let unqualified: Vec<&str> = uses
776 .iter()
777 .filter(|entry| !entry.alternative.contains('('))
778 .map(|entry| entry.chord)
779 .collect();
780 assert!(
781 unqualified.len() <= 1,
782 "{key} is advertised for more than one action without naming the \
783 context that selects between them: {unqualified:?}"
784 );
785 }
786 }
787
788 /// #440 / #3758: `Ctrl+G` and `Ctrl+S` stash the draft. They are not a
789 /// send, a queue, a steer, or a file save, and a real-terminal report that
790 /// says otherwise is reading ambiguous copy, not misusing the key.
791 #[test]
792 fn stash_chords_advertise_stashing_and_nothing_else() {
793 let stash_entries: Vec<&KeybindingEntry> = KEYBINDINGS
794 .iter()
795 .filter(|entry| {
796 entry
797 .chord
798 .split(" / ")
799 .map(str::trim)
800 .any(|chord| matches!(chord, "Ctrl+G" | "Ctrl+S"))
801 })
802 .collect();
803
804 assert_eq!(
805 stash_entries.len(),
806 1,
807 "Ctrl+G / Ctrl+S must be documented exactly once, together"
808 );
809 assert_eq!(stash_entries[0].chord, "Ctrl+G / Ctrl+S");
810 assert_eq!(
811 stash_entries[0].description_id,
812 codewhale_localization::MessageId::KbStashDraft
813 );
814
815 let copy = codewhale_localization::tr(
816 codewhale_localization::Locale::En,
817 codewhale_localization::MessageId::KbStashDraft,
818 )
819 .to_ascii_lowercase();
820 for forbidden in ["send", "queue", "steer", "submit", "save"] {
821 assert!(
822 !copy.contains(forbidden),
823 "stash copy must not read as {forbidden:?}: {copy:?}"
824 );
825 }
826 assert!(
827 copy.contains("stash"),
828 "stash copy must name the action it performs: {copy:?}"
829 );
830 }
831
832 /// Only chords distinguishable by the baseline terminal protocol may be
833 /// advertised. Enter sends or queues (then sends a queued message now),
834 /// while the newline chords stay newlines.
835 #[test]
836 fn running_turn_verbs_belong_to_one_chord_each() {
837 let entry_for = |id| {
838 KEYBINDINGS
839 .iter()
840 .find(|entry| entry.description_id == id)
841 .expect("running-turn binding should be documented")
842 };
843
844 assert_eq!(
845 entry_for(codewhale_localization::MessageId::KbSendDraft).chord,
846 "Enter"
847 );
848 assert!(
849 KEYBINDINGS
850 .iter()
851 .all(|entry| !entry.chord.contains("Ctrl+Enter")
852 && !entry.chord.contains("Cmd+Enter"))
853 );
854 assert_eq!(
855 entry_for(codewhale_localization::MessageId::KbInsertNewline).chord,
856 "Ctrl+J / Alt+Enter / Shift+Enter (enhanced terminals)"
857 );
858
859 let newline_copy = codewhale_localization::tr(
860 codewhale_localization::Locale::En,
861 codewhale_localization::MessageId::KbInsertNewline,
862 )
863 .to_ascii_lowercase();
864 for forbidden in ["send", "steer", "queue"] {
865 assert!(
866 !newline_copy.contains(forbidden),
867 "newline chords must not read as {forbidden:?}: {newline_copy:?}"
868 );
869 }
870 }
871
872 #[test]
873 fn section_rank_is_a_total_order() {
874 let sections = [
875 KeybindingSection::Navigation,
876 KeybindingSection::Editing,
877 KeybindingSection::Submission,
878 KeybindingSection::Modes,
879 KeybindingSection::Sessions,
880 KeybindingSection::Clipboard,
881 KeybindingSection::Help,
882 ];
883 let mut ranks: Vec<u8> = sections.iter().map(|s| s.rank()).collect();
884 ranks.sort_unstable();
885 ranks.dedup();
886 assert_eq!(ranks.len(), sections.len(), "ranks must be unique");
887 }
888
889 // ------------------------------------------------------------------
890 // docs/KEYBINDINGS.md <-> KEYBINDINGS bidirectional drift gate
891 // ------------------------------------------------------------------
892
893 const KEYBINDINGS_DOC: &str = include_str!(concat!(
894 env!("CARGO_MANIFEST_DIR"),
895 "/../../docs/KEYBINDINGS.md"
896 ));
897
898 /// Doc table chords that are deliberately NOT in the help catalog.
899 /// Every entry needs a justification; adding one is a reviewed decision.
900 const DOC_CHORD_ALLOWLIST: &[&str] = &[
901 // Ctrl+Enter / Cmd+Enter: works, but deliberately unadvertised —
902 // several terminals encode it exactly like bare Enter (handler
903 // comment at ui.rs is_forced_submit_key call site; absence pinned by
904 // composer_catalog_assigns_one_stable_role_to_each_chord).
905 "ctrlenter",
906 // Ctrl+click / Cmd+click opens OSC 8 links — terminal-owned.
907 "ctrlclick",
908 // Ctrl+N navigates the slash-command menu; menu-local chords are
909 // documented in the md but intentionally not help-catalog entries.
910 "ctrln",
911 ];
912
913 /// Normalize a chord for comparison: lowercase, macOS aliases folded
914 /// (Option -> Alt, Cmd -> Ctrl), all separators stripped, so `Ctrl-Home`,
915 /// `Ctrl+Home`, and `Ctrl + Home` compare equal.
916 fn normalize_chord(raw: &str) -> String {
917 raw.to_lowercase()
918 .replace("option", "alt")
919 .replace("cmd", "ctrl")
920 .chars()
921 .filter(|c| !matches!(c, '-' | '+' | ' ' | '`'))
922 .collect()
923 }
924
925 /// `Alt+1-8`-style digit family -> one normalized atom per digit.
926 fn expand_digit_family(lowered: &str) -> Option<Vec<String>> {
927 let (head, tail) = lowered.rsplit_once('-')?;
928 let end: u32 = tail.parse().ok()?;
929 let start: u32 = head.chars().next_back()?.to_digit(10)?;
930 let prefix = &head[..head.len() - 1];
931 if !matches!(prefix, "alt+" | "ctrl+" | "shift+") || start > end {
932 return None;
933 }
934 Some(
935 (start..=end)
936 .map(|digit| normalize_chord(&format!("{prefix}{digit}")))
937 .collect(),
938 )
939 }
940
941 /// Expand one chord segment (no ` / ` separators) into normalized atoms.
942 /// Handles suffix families: `Ctrl+Home/End` -> ctrlhome + ctrlend,
943 /// `Ctrl+Shift+←/→` -> ctrlshift← + ctrlshift→.
944 fn segment_atoms(segment: &str, out: &mut Vec<String>) {
945 let lowered = segment
946 .trim()
947 .to_lowercase()
948 .replace("option", "alt")
949 .replace("cmd", "ctrl");
950 if lowered.is_empty() {
951 return;
952 }
953 if let Some(family) = expand_digit_family(&lowered) {
954 out.extend(family);
955 return;
956 }
957 let mut prefix = String::new();
958 let mut rest = lowered.as_str();
959 loop {
960 let mut consumed = false;
961 for modifier in ["ctrl+", "alt+", "shift+", "ctrl-", "alt-", "shift-"] {
962 if let Some(stripped) = rest.strip_prefix(modifier) {
963 prefix.push_str(&modifier[..modifier.len() - 1]);
964 prefix.push('+');
965 rest = stripped;
966 consumed = true;
967 break;
968 }
969 }
970 if !consumed {
971 break;
972 }
973 }
974 let parts: Vec<&str> = rest.split('/').collect();
975 if !prefix.is_empty() && parts.len() > 1 && parts.iter().all(|p| !p.is_empty()) {
976 for part in parts {
977 out.push(normalize_chord(&format!("{prefix}{part}")));
978 }
979 } else {
980 out.push(normalize_chord(&lowered));
981 }
982 }
983
984 /// Normalized chord atoms advertised by the help catalog. Qualified
985 /// entries (`/context`, `@path`, `Right click`) are commands or mouse
986 /// gestures, not chords, and are skipped by design.
987 fn catalog_chord_atoms() -> Vec<String> {
988 let mut out = Vec::new();
989 for entry in KEYBINDINGS {
990 let chord = entry.chord.split(" (").next().unwrap_or(entry.chord);
991 for segment in chord.split(" / ") {
992 let segment = segment.trim();
993 if segment.starts_with('/') || segment.starts_with('@') || segment.contains("click")
994 {
995 continue;
996 }
997 segment_atoms(segment, &mut out);
998 }
999 }
1000 out
1001 }
1002
1003 /// Normalized chord atoms from backticked tokens in docs/KEYBINDINGS.md
1004 /// table rows. Prose backticks (terminal-local notes) are excluded by
1005 /// only reading `| ... |` lines; non-chord tokens (slash commands,
1006 /// mentions, mouse drags, single letters) are filtered out.
1007 fn doc_table_chord_atoms() -> Vec<String> {
1008 const NAMED: &[&str] = &[
1009 "tab",
1010 "esc",
1011 "enter",
1012 "backspace",
1013 "delete",
1014 "home",
1015 "end",
1016 "pgup",
1017 "pgdn",
1018 "↑",
1019 "↓",
1020 "←",
1021 "→",
1022 ];
1023 let mut out = Vec::new();
1024 for line in KEYBINDINGS_DOC.lines() {
1025 if !line.trim_start().starts_with('|') {
1026 continue;
1027 }
1028 for token in line.split('`').skip(1).step_by(2) {
1029 let lowered = token.to_lowercase();
1030 if lowered.starts_with('/') || lowered.starts_with('@') || lowered.starts_with('!')
1031 {
1032 continue;
1033 }
1034 let has_modifier = ["ctrl", "alt", "shift", "option", "cmd"]
1035 .iter()
1036 .any(|m| lowered.contains(m));
1037 let is_named = NAMED.contains(&lowered.as_str())
1038 || (lowered.len() == 2
1039 && lowered.starts_with('f')
1040 && lowered[1..].parse::<u8>().is_ok());
1041 let is_named_combo = lowered.contains(' ')
1042 && lowered.split(' ').all(|part| {
1043 let part = part.trim();
1044 NAMED.contains(&part)
1045 || ["ctrl", "alt", "shift", "option", "cmd"]
1046 .iter()
1047 .any(|m| part.contains(m))
1048 });
1049 if has_modifier || is_named || is_named_combo {
1050 segment_atoms(token, &mut out);
1051 }
1052 }
1053 }
1054 out
1055 }
1056
1057 #[test]
1058 fn keybindings_md_and_help_catalog_do_not_drift() {
1059 use std::collections::BTreeSet;
1060
1061 let catalog: BTreeSet<String> = catalog_chord_atoms().into_iter().collect();
1062 let doc_atoms: BTreeSet<String> = doc_table_chord_atoms().into_iter().collect();
1063 let allowlist: BTreeSet<&str> = DOC_CHORD_ALLOWLIST.iter().copied().collect();
1064
1065 // Direction 1: every chord a docs table advertises must be in the
1066 // help catalog (or an explicitly justified exception).
1067 let undocumented: Vec<&String> = doc_atoms
1068 .iter()
1069 .filter(|atom| !catalog.contains(*atom) && !allowlist.contains(atom.as_str()))
1070 .collect();
1071 assert!(
1072 undocumented.is_empty(),
1073 "docs/KEYBINDINGS.md advertises chords missing from the KEYBINDINGS \
1074 catalog — add the binding, fix the docs, or justify an allowlist entry: \
1075 {undocumented:?}"
1076 );
1077
1078 // Direction 2: every catalog chord must be documented in the md —
1079 // either as an expanded table token (doc_atoms) or anywhere in the
1080 // normalized prose (e.g. Backspace/Delete in the selection notes).
1081 let normalized_doc = normalize_chord(KEYBINDINGS_DOC);
1082 let undocumented: Vec<&String> = catalog
1083 .iter()
1084 .filter(|atom| !doc_atoms.contains(*atom) && !normalized_doc.contains(atom.as_str()))
1085 .collect();
1086 assert!(
1087 undocumented.is_empty(),
1088 "KEYBINDINGS catalog advertises chords absent from docs/KEYBINDINGS.md \
1089 — document the chord or remove it from the catalog: {undocumented:?}"
1090 );
1091 }
1092 }
1093
1093 lines RUST