返回 DeepSeek-TUI-2026
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 (`Alt+1/2/3`) that don't map cleanly to a single
22 //! chord.
23
24 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25 pub enum KeybindingSection {
26 Navigation,
27 Editing,
28 Submission,
29 Modes,
30 Sessions,
31 Clipboard,
32 Help,
33 }
34
35 impl KeybindingSection {
36 pub fn label(self, locale: crate::localization::Locale) -> &'static str {
37 use crate::localization::{MessageId, tr};
38 let id = match self {
39 Self::Navigation => MessageId::HelpSectionNavigation,
40 Self::Editing => MessageId::HelpSectionEditing,
41 Self::Submission => MessageId::HelpSectionActions,
42 Self::Modes => MessageId::HelpSectionModes,
43 Self::Sessions => MessageId::HelpSectionSessions,
44 Self::Clipboard => MessageId::HelpSectionClipboard,
45 Self::Help => MessageId::HelpSectionHelp,
46 };
47 tr(locale, id)
48 }
49
50 /// Stable ordering for help rendering — matches the variant declaration
51 /// order; explicit so adding a section forces a deliberate placement.
52 pub fn rank(self) -> u8 {
53 match self {
54 Self::Navigation => 0,
55 Self::Editing => 1,
56 Self::Submission => 2,
57 Self::Modes => 3,
58 Self::Sessions => 4,
59 Self::Clipboard => 5,
60 Self::Help => 6,
61 }
62 }
63 }
64
65 #[derive(Debug, Clone, Copy)]
66 pub struct KeybindingEntry {
67 pub chord: &'static str,
68 pub description_id: crate::localization::MessageId,
69 pub section: KeybindingSection,
70 }
71
72 /// Canonical list of keybindings shown in the help overlay.
73 ///
74 /// Strings are written in the same notation the existing help screen uses so
75 /// readers can cross-reference with documentation: `Ctrl+X`, `Alt+X`,
76 /// `Shift+X`, `↑/↓`, `PgUp/PgDn`, etc. Help renderers may apply per-platform
77 /// substitutions (e.g. `⌥` for Alt on macOS) at render time, but the catalog
78 /// itself stores the portable form.
79 pub const KEYBINDINGS: &[KeybindingEntry] = &[
80 // --- Navigation ---
81 KeybindingEntry {
82 chord: "↑ / ↓",
83 description_id: crate::localization::MessageId::KbScrollTranscript,
84 section: KeybindingSection::Navigation,
85 },
86 KeybindingEntry {
87 chord: "Ctrl+↑ / Ctrl+↓",
88 description_id: crate::localization::MessageId::KbNavigateHistory,
89 section: KeybindingSection::Navigation,
90 },
91 KeybindingEntry {
92 chord: "Alt+↑ / Alt+↓",
93 description_id: crate::localization::MessageId::KbScrollTranscriptAlt,
94 section: KeybindingSection::Navigation,
95 },
96 KeybindingEntry {
97 chord: "PgUp / PgDn",
98 description_id: crate::localization::MessageId::KbScrollPage,
99 section: KeybindingSection::Navigation,
100 },
101 KeybindingEntry {
102 chord: "Home / End",
103 description_id: crate::localization::MessageId::KbJumpTopBottom,
104 section: KeybindingSection::Navigation,
105 },
106 KeybindingEntry {
107 chord: "g / G",
108 description_id: crate::localization::MessageId::KbJumpTopBottomEmpty,
109 section: KeybindingSection::Navigation,
110 },
111 KeybindingEntry {
112 chord: "[ / ]",
113 description_id: crate::localization::MessageId::KbJumpToolBlocks,
114 section: KeybindingSection::Navigation,
115 },
116 // --- Editing ---
117 KeybindingEntry {
118 chord: "← / →",
119 description_id: crate::localization::MessageId::KbMoveCursor,
120 section: KeybindingSection::Editing,
121 },
122 KeybindingEntry {
123 chord: "Ctrl+A / Ctrl+E",
124 description_id: crate::localization::MessageId::KbJumpLineStartEnd,
125 section: KeybindingSection::Editing,
126 },
127 KeybindingEntry {
128 chord: "Backspace / Delete",
129 description_id: crate::localization::MessageId::KbDeleteChar,
130 section: KeybindingSection::Editing,
131 },
132 KeybindingEntry {
133 chord: "Ctrl+U",
134 description_id: crate::localization::MessageId::KbClearDraft,
135 section: KeybindingSection::Editing,
136 },
137 KeybindingEntry {
138 chord: "Ctrl+S",
139 description_id: crate::localization::MessageId::KbStashDraft,
140 section: KeybindingSection::Editing,
141 },
142 KeybindingEntry {
143 chord: "Alt+R",
144 description_id: crate::localization::MessageId::KbSearchHistory,
145 section: KeybindingSection::Editing,
146 },
147 KeybindingEntry {
148 chord: "Ctrl+J / Alt+Enter / Shift+Enter",
149 description_id: crate::localization::MessageId::KbInsertNewline,
150 section: KeybindingSection::Editing,
151 },
152 // --- Submission / actions ---
153 KeybindingEntry {
154 chord: "Enter",
155 description_id: crate::localization::MessageId::KbSendDraft,
156 section: KeybindingSection::Submission,
157 },
158 KeybindingEntry {
159 chord: "Esc",
160 description_id: crate::localization::MessageId::KbCloseMenu,
161 section: KeybindingSection::Submission,
162 },
163 KeybindingEntry {
164 chord: "Ctrl+C",
165 description_id: crate::localization::MessageId::KbCancelOrExit,
166 section: KeybindingSection::Submission,
167 },
168 KeybindingEntry {
169 chord: "Ctrl+B",
170 description_id: crate::localization::MessageId::KbShellControls,
171 section: KeybindingSection::Submission,
172 },
173 KeybindingEntry {
174 chord: "Ctrl+D",
175 description_id: crate::localization::MessageId::KbExitEmpty,
176 section: KeybindingSection::Submission,
177 },
178 KeybindingEntry {
179 chord: "Ctrl+K",
180 description_id: crate::localization::MessageId::KbCommandPalette,
181 section: KeybindingSection::Submission,
182 },
183 KeybindingEntry {
184 chord: "Ctrl+P",
185 description_id: crate::localization::MessageId::KbFuzzyFilePicker,
186 section: KeybindingSection::Submission,
187 },
188 KeybindingEntry {
189 chord: "Alt+C",
190 description_id: crate::localization::MessageId::KbCompactInspector,
191 section: KeybindingSection::Submission,
192 },
193 KeybindingEntry {
194 chord: "l",
195 description_id: crate::localization::MessageId::KbLastMessagePager,
196 section: KeybindingSection::Submission,
197 },
198 KeybindingEntry {
199 chord: "v",
200 description_id: crate::localization::MessageId::KbSelectedDetails,
201 section: KeybindingSection::Submission,
202 },
203 KeybindingEntry {
204 chord: "Alt+V",
205 description_id: crate::localization::MessageId::KbToolDetailsPager,
206 section: KeybindingSection::Submission,
207 },
208 KeybindingEntry {
209 chord: "Ctrl+O",
210 description_id: crate::localization::MessageId::KbThinkingPager,
211 section: KeybindingSection::Submission,
212 },
213 KeybindingEntry {
214 chord: "Ctrl+T",
215 description_id: crate::localization::MessageId::KbLiveTranscript,
216 section: KeybindingSection::Submission,
217 },
218 KeybindingEntry {
219 chord: "Esc Esc",
220 description_id: crate::localization::MessageId::KbBacktrackMessage,
221 section: KeybindingSection::Submission,
222 },
223 // --- Modes ---
224 KeybindingEntry {
225 chord: "Tab / Shift+Tab",
226 description_id: crate::localization::MessageId::KbCompleteCycleModes,
227 section: KeybindingSection::Modes,
228 },
229 KeybindingEntry {
230 chord: "Alt+1 / Alt+2 / Alt+3",
231 description_id: crate::localization::MessageId::KbJumpPlanAgentYolo,
232 section: KeybindingSection::Modes,
233 },
234 KeybindingEntry {
235 chord: "Alt+P / Alt+A / Alt+Y",
236 description_id: crate::localization::MessageId::KbAltJumpPlanAgentYolo,
237 section: KeybindingSection::Modes,
238 },
239 KeybindingEntry {
240 chord: "Alt+! / Alt+@ / Alt+# / Alt+4 / Alt+$ / Alt+0",
241 description_id: crate::localization::MessageId::KbFocusSidebar,
242 section: KeybindingSection::Modes,
243 },
244 KeybindingEntry {
245 chord: "Ctrl+X",
246 description_id: crate::localization::MessageId::KbTogglePlanAgent,
247 section: KeybindingSection::Modes,
248 },
249 // --- Sessions ---
250 KeybindingEntry {
251 chord: "Ctrl+R",
252 description_id: crate::localization::MessageId::KbSessionPicker,
253 section: KeybindingSection::Sessions,
254 },
255 // --- Clipboard ---
256 KeybindingEntry {
257 chord: "Ctrl+V",
258 description_id: crate::localization::MessageId::KbPasteAttach,
259 section: KeybindingSection::Clipboard,
260 },
261 KeybindingEntry {
262 chord: "Ctrl+Shift+C",
263 description_id: crate::localization::MessageId::KbCopySelection,
264 section: KeybindingSection::Clipboard,
265 },
266 KeybindingEntry {
267 chord: "Right click",
268 description_id: crate::localization::MessageId::KbContextMenu,
269 section: KeybindingSection::Clipboard,
270 },
271 KeybindingEntry {
272 chord: "@path",
273 description_id: crate::localization::MessageId::KbAttachPath,
274 section: KeybindingSection::Clipboard,
275 },
276 // --- Help ---
277 KeybindingEntry {
278 chord: "?",
279 description_id: crate::localization::MessageId::KbHelpOverlay,
280 section: KeybindingSection::Help,
281 },
282 KeybindingEntry {
283 chord: "F1",
284 description_id: crate::localization::MessageId::KbToggleHelp,
285 section: KeybindingSection::Help,
286 },
287 KeybindingEntry {
288 chord: "Ctrl+/",
289 description_id: crate::localization::MessageId::KbToggleHelp,
290 section: KeybindingSection::Help,
291 },
292 ];
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297
298 #[test]
299 fn catalog_is_non_empty_and_sections_have_entries() {
300 assert!(!KEYBINDINGS.is_empty());
301 // Every declared section should appear in the catalog at least once,
302 // otherwise the help overlay would render an empty heading.
303 let sections = [
304 KeybindingSection::Navigation,
305 KeybindingSection::Editing,
306 KeybindingSection::Submission,
307 KeybindingSection::Modes,
308 KeybindingSection::Sessions,
309 KeybindingSection::Clipboard,
310 KeybindingSection::Help,
311 ];
312 for section in sections {
313 assert!(
314 KEYBINDINGS.iter().any(|entry| entry.section == section),
315 "no entries for section {:?}",
316 section
317 );
318 }
319 }
320
321 #[test]
322 fn help_section_documents_question_mark() {
323 // The whole point of #93 is that `?` opens this overlay; if the entry
324 // ever disappears the user-facing discoverability promise breaks.
325 assert!(
326 KEYBINDINGS
327 .iter()
328 .any(|entry| entry.chord.contains('?') && entry.section == KeybindingSection::Help),
329 "`?` must remain documented as the help-toggle chord"
330 );
331 }
332
333 #[test]
334 fn section_rank_is_a_total_order() {
335 let sections = [
336 KeybindingSection::Navigation,
337 KeybindingSection::Editing,
338 KeybindingSection::Submission,
339 KeybindingSection::Modes,
340 KeybindingSection::Sessions,
341 KeybindingSection::Clipboard,
342 KeybindingSection::Help,
343 ];
344 let mut ranks: Vec<u8> = sections.iter().map(|s| s.rank()).collect();
345 ranks.sort_unstable();
346 ranks.dedup();
347 assert_eq!(ranks.len(), sections.len(), "ranks must be unique");
348 }
349 }
350
350 lines RUST