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