Skip to main content

codex_config/
tui_keymap.rs

1//! TUI keymap config schema and canonical key-spec normalization.
2//!
3//! This module defines the on-disk `[tui.keymap]` contract used by
4//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical
5//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`.
6//!
7//! Responsibilities:
8//!
9//! 1. Define strongly typed config contexts/actions with unknown-field
10//!    rejection.
11//! 2. Normalize accepted key aliases into canonical names.
12//! 3. Reject malformed bindings early with user-facing diagnostics.
13//!
14//! Non-responsibilities:
15//!
16//! 1. Dispatch precedence and conflict validation.
17//! 2. Input event matching at runtime.
18
19use schemars::JsonSchema;
20use serde::Deserialize;
21use serde::Deserializer;
22use serde::Serialize;
23use serde::de::Error as SerdeError;
24use std::collections::BTreeMap;
25
26/// Highest function key supported by portable TUI keymap configuration.
27pub const MAX_FUNCTION_KEY: u8 = 24;
28
29/// Normalized string representation of a single key event (for example `ctrl-a`).
30///
31/// The parser accepts a small alias set (for example `escape` -> `esc`,
32/// `pageup` -> `page-up`) and stores the canonical form.
33///
34/// This deliberately represents one terminal key event, not a sequence of
35/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding
36/// multi-step chords would require a separate runtime state machine.
37#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
38#[serde(transparent)]
39pub struct KeybindingSpec(#[schemars(with = "String")] pub String);
40
41impl KeybindingSpec {
42    /// Returns the canonical key-spec string (for example `ctrl-a`).
43    pub fn as_str(&self) -> &str {
44        self.0.as_str()
45    }
46}
47
48impl<'de> Deserialize<'de> for KeybindingSpec {
49    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
50    where
51        D: Deserializer<'de>,
52    {
53        let raw = String::deserialize(deserializer)?;
54        let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?;
55        Ok(Self(normalized))
56    }
57}
58
59/// One action binding value in config.
60///
61/// This accepts either:
62///
63/// 1. A single key spec string (`"ctrl-a"`).
64/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`).
65///
66/// An empty list explicitly unbinds the action in that scope. Because an
67/// explicit empty list is still a configured value, runtime resolution must not
68/// fall through to global or built-in defaults for that action.
69#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
70#[serde(untagged)]
71pub enum KeybindingsSpec {
72    One(KeybindingSpec),
73    Many(Vec<KeybindingSpec>),
74}
75
76impl KeybindingsSpec {
77    /// Returns all configured key specs for one action in declaration order.
78    ///
79    /// Callers should preserve this ordering when deriving UI hints so the
80    /// first binding remains the primary affordance shown to users.
81    pub fn specs(&self) -> Vec<&KeybindingSpec> {
82        match self {
83            Self::One(spec) => vec![spec],
84            Self::Many(specs) => specs.iter().collect(),
85        }
86    }
87}
88
89/// Global keybindings. These are used when a context does not define an override.
90#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
91#[serde(deny_unknown_fields)]
92#[schemars(deny_unknown_fields)]
93pub struct TuiGlobalKeymap {
94    /// Open the transcript overlay.
95    pub open_transcript: Option<KeybindingsSpec>,
96    /// Open the external editor for the current draft.
97    pub open_external_editor: Option<KeybindingsSpec>,
98    /// Copy the last agent response to the clipboard.
99    pub copy: Option<KeybindingsSpec>,
100    /// Clear the terminal UI.
101    pub clear_terminal: Option<KeybindingsSpec>,
102    /// Submit the current composer draft.
103    pub submit: Option<KeybindingsSpec>,
104    /// Queue the current composer draft while a task is running.
105    pub queue: Option<KeybindingsSpec>,
106    /// Toggle the composer shortcut overlay.
107    pub toggle_shortcuts: Option<KeybindingsSpec>,
108    /// Toggle Vim mode for the composer input.
109    pub toggle_vim_mode: Option<KeybindingsSpec>,
110    /// Toggle Fast mode.
111    pub toggle_fast_mode: Option<KeybindingsSpec>,
112    /// Toggle raw scrollback mode for copy-friendly transcript selection.
113    pub toggle_raw_output: Option<KeybindingsSpec>,
114}
115
116/// Chat context keybindings.
117#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
118#[serde(deny_unknown_fields)]
119#[schemars(deny_unknown_fields)]
120pub struct TuiChatKeymap {
121    /// Interrupt the active turn.
122    pub interrupt_turn: Option<KeybindingsSpec>,
123    /// Decrease the active reasoning effort.
124    pub decrease_reasoning_effort: Option<KeybindingsSpec>,
125    /// Increase the active reasoning effort.
126    pub increase_reasoning_effort: Option<KeybindingsSpec>,
127    /// Edit the most recently queued message.
128    pub edit_queued_message: Option<KeybindingsSpec>,
129}
130
131/// Composer context keybindings. These override corresponding `global` actions.
132#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
133#[serde(deny_unknown_fields)]
134#[schemars(deny_unknown_fields)]
135pub struct TuiComposerKeymap {
136    /// Submit the current composer draft.
137    pub submit: Option<KeybindingsSpec>,
138    /// Queue the current composer draft while a task is running.
139    pub queue: Option<KeybindingsSpec>,
140    /// Toggle the composer shortcut overlay.
141    pub toggle_shortcuts: Option<KeybindingsSpec>,
142    /// Open reverse history search or move to the previous match.
143    pub history_search_previous: Option<KeybindingsSpec>,
144    /// Move to the next match in reverse history search.
145    pub history_search_next: Option<KeybindingsSpec>,
146}
147
148/// Editor context keybindings for text editing inside text areas.
149#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
150#[serde(deny_unknown_fields)]
151#[schemars(deny_unknown_fields)]
152pub struct TuiEditorKeymap {
153    /// Insert a newline in the editor.
154    pub insert_newline: Option<KeybindingsSpec>,
155    /// Move cursor left by one grapheme.
156    pub move_left: Option<KeybindingsSpec>,
157    /// Move cursor right by one grapheme.
158    pub move_right: Option<KeybindingsSpec>,
159    /// Move cursor up one visual line.
160    pub move_up: Option<KeybindingsSpec>,
161    /// Move cursor down one visual line.
162    pub move_down: Option<KeybindingsSpec>,
163    /// Move cursor to beginning of previous word.
164    pub move_word_left: Option<KeybindingsSpec>,
165    /// Move cursor to end of next word.
166    pub move_word_right: Option<KeybindingsSpec>,
167    /// Move cursor to beginning of line.
168    pub move_line_start: Option<KeybindingsSpec>,
169    /// Move cursor to end of line.
170    pub move_line_end: Option<KeybindingsSpec>,
171    /// Delete one grapheme to the left.
172    pub delete_backward: Option<KeybindingsSpec>,
173    /// Delete one grapheme to the right.
174    pub delete_forward: Option<KeybindingsSpec>,
175    /// Delete the previous word.
176    pub delete_backward_word: Option<KeybindingsSpec>,
177    /// Delete the next word.
178    pub delete_forward_word: Option<KeybindingsSpec>,
179    /// Kill text from cursor to line start.
180    pub kill_line_start: Option<KeybindingsSpec>,
181    /// Kill the current line.
182    pub kill_whole_line: Option<KeybindingsSpec>,
183    /// Kill text from cursor to line end.
184    pub kill_line_end: Option<KeybindingsSpec>,
185    /// Yank the kill buffer.
186    pub yank: Option<KeybindingsSpec>,
187}
188
189/// Vim normal-mode keybindings for modal editing inside text areas.
190///
191/// Actions that use uppercase letters (like `A` for append-line-end) should
192/// be specified as `shift-a` in config; the runtime matcher handles
193/// cross-terminal shift-reporting differences automatically.
194#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
195#[schemars(deny_unknown_fields)]
196pub struct TuiVimNormalKeymap {
197    /// Enter insert mode at cursor (`i`).
198    pub enter_insert: Option<KeybindingsSpec>,
199    /// Enter insert mode after cursor (`a`).
200    pub append_after_cursor: Option<KeybindingsSpec>,
201    /// Enter insert mode at end of line (`A`).
202    pub append_line_end: Option<KeybindingsSpec>,
203    /// Enter insert mode at first non-blank of line (`I`).
204    pub insert_line_start: Option<KeybindingsSpec>,
205    /// Open a new line below and enter insert mode (`o`).
206    pub open_line_below: Option<KeybindingsSpec>,
207    /// Open a new line above and enter insert mode (`O`).
208    pub open_line_above: Option<KeybindingsSpec>,
209    /// Move cursor left (`h`).
210    pub move_left: Option<KeybindingsSpec>,
211    /// Move cursor right (`l`).
212    pub move_right: Option<KeybindingsSpec>,
213    /// Move cursor up (`k`), or recall older composer history at history boundaries.
214    pub move_up: Option<KeybindingsSpec>,
215    /// Move cursor down (`j`), or recall newer composer history at history boundaries.
216    pub move_down: Option<KeybindingsSpec>,
217    /// Move cursor to start of next word (`w`).
218    pub move_word_forward: Option<KeybindingsSpec>,
219    /// Move cursor to start of previous word (`b`).
220    pub move_word_backward: Option<KeybindingsSpec>,
221    /// Move cursor to end of current/next word (`e`).
222    pub move_word_end: Option<KeybindingsSpec>,
223    /// Move cursor to start of line (`0`).
224    pub move_line_start: Option<KeybindingsSpec>,
225    /// Move cursor to end of line (`$`).
226    pub move_line_end: Option<KeybindingsSpec>,
227    /// Delete character under cursor (`x`).
228    pub delete_char: Option<KeybindingsSpec>,
229    /// Delete character under cursor and enter insert mode (`s`).
230    pub substitute_char: Option<KeybindingsSpec>,
231    /// Delete from cursor to end of line (`D`).
232    pub delete_to_line_end: Option<KeybindingsSpec>,
233    /// Change from cursor to end of line and enter insert mode (`C`).
234    pub change_to_line_end: Option<KeybindingsSpec>,
235    /// Yank the entire line (`Y`).
236    pub yank_line: Option<KeybindingsSpec>,
237    /// Paste after cursor (`p`).
238    pub paste_after: Option<KeybindingsSpec>,
239    /// Begin delete operator; next key selects motion (`d`).
240    pub start_delete_operator: Option<KeybindingsSpec>,
241    /// Begin yank operator; next key selects motion (`y`).
242    pub start_yank_operator: Option<KeybindingsSpec>,
243    /// Begin change operator; next keys select a text object.
244    pub start_change_operator: Option<KeybindingsSpec>,
245    /// Cancel a pending operator and return to normal mode.
246    pub cancel_operator: Option<KeybindingsSpec>,
247}
248
249/// Vim operator-pending keybindings for modal editing inside text areas.
250///
251/// This context is active only while waiting for a motion after `d` or `y`.
252/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
253/// `Esc` cancels the pending operator and returns to normal mode without
254/// modifying text.
255#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
256#[schemars(deny_unknown_fields)]
257pub struct TuiVimOperatorKeymap {
258    /// Repeat delete operator to delete the whole line (`dd`).
259    pub delete_line: Option<KeybindingsSpec>,
260    /// Repeat yank operator to yank the whole line (`yy`).
261    pub yank_line: Option<KeybindingsSpec>,
262    /// Motion: left (`h`).
263    pub motion_left: Option<KeybindingsSpec>,
264    /// Motion: right (`l`).
265    pub motion_right: Option<KeybindingsSpec>,
266    /// Motion: up one line (`k`).
267    pub motion_up: Option<KeybindingsSpec>,
268    /// Motion: down one line (`j`).
269    pub motion_down: Option<KeybindingsSpec>,
270    /// Motion: to start of next word (`w`).
271    pub motion_word_forward: Option<KeybindingsSpec>,
272    /// Motion: to start of previous word (`b`).
273    pub motion_word_backward: Option<KeybindingsSpec>,
274    /// Motion: to end of current/next word (`e`).
275    pub motion_word_end: Option<KeybindingsSpec>,
276    /// Motion: to start of line (`0`).
277    pub motion_line_start: Option<KeybindingsSpec>,
278    /// Motion: to end of line (`$`).
279    pub motion_line_end: Option<KeybindingsSpec>,
280    /// Select an inner text object after an operator.
281    pub select_inner_text_object: Option<KeybindingsSpec>,
282    /// Select an around text object after an operator.
283    pub select_around_text_object: Option<KeybindingsSpec>,
284    /// Cancel the pending operator and return to normal mode.
285    pub cancel: Option<KeybindingsSpec>,
286}
287
288/// Vim text-object keybindings for modal editing inside text areas.
289#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
290#[serde(deny_unknown_fields)]
291#[schemars(deny_unknown_fields)]
292pub struct TuiVimTextObjectKeymap {
293    /// Text object: word.
294    pub word: Option<KeybindingsSpec>,
295    /// Text object: whitespace-delimited WORD.
296    pub big_word: Option<KeybindingsSpec>,
297    /// Text object: parentheses.
298    pub parentheses: Option<KeybindingsSpec>,
299    /// Text object: brackets.
300    pub brackets: Option<KeybindingsSpec>,
301    /// Text object: braces.
302    pub braces: Option<KeybindingsSpec>,
303    /// Text object: double quotes.
304    pub double_quote: Option<KeybindingsSpec>,
305    /// Text object: single quotes.
306    pub single_quote: Option<KeybindingsSpec>,
307    /// Text object: backticks.
308    pub backtick: Option<KeybindingsSpec>,
309    /// Cancel the pending text-object command.
310    pub cancel: Option<KeybindingsSpec>,
311}
312
313/// Pager context keybindings for transcript and static overlays.
314#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
315#[serde(deny_unknown_fields)]
316#[schemars(deny_unknown_fields)]
317pub struct TuiPagerKeymap {
318    /// Scroll up by one row.
319    pub scroll_up: Option<KeybindingsSpec>,
320    /// Scroll down by one row.
321    pub scroll_down: Option<KeybindingsSpec>,
322    /// Scroll up by one page.
323    pub page_up: Option<KeybindingsSpec>,
324    /// Scroll down by one page.
325    pub page_down: Option<KeybindingsSpec>,
326    /// Scroll up by half a page.
327    pub half_page_up: Option<KeybindingsSpec>,
328    /// Scroll down by half a page.
329    pub half_page_down: Option<KeybindingsSpec>,
330    /// Jump to the beginning.
331    pub jump_top: Option<KeybindingsSpec>,
332    /// Jump to the end.
333    pub jump_bottom: Option<KeybindingsSpec>,
334    /// Close the pager overlay.
335    pub close: Option<KeybindingsSpec>,
336    /// Close the transcript overlay via its dedicated toggle key.
337    pub close_transcript: Option<KeybindingsSpec>,
338}
339
340/// List selection context keybindings for popup-style selectable lists.
341#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
342#[serde(deny_unknown_fields)]
343#[schemars(deny_unknown_fields)]
344pub struct TuiListKeymap {
345    /// Move list selection up.
346    pub move_up: Option<KeybindingsSpec>,
347    /// Move list selection down.
348    pub move_down: Option<KeybindingsSpec>,
349    /// Move horizontally left in list pickers that support horizontal actions.
350    pub move_left: Option<KeybindingsSpec>,
351    /// Move horizontally right in list pickers that support horizontal actions.
352    pub move_right: Option<KeybindingsSpec>,
353    /// Move list selection up by one page.
354    pub page_up: Option<KeybindingsSpec>,
355    /// Move list selection down by one page.
356    pub page_down: Option<KeybindingsSpec>,
357    /// Jump to the first list item.
358    pub jump_top: Option<KeybindingsSpec>,
359    /// Jump to the last list item.
360    pub jump_bottom: Option<KeybindingsSpec>,
361    /// Accept current selection.
362    pub accept: Option<KeybindingsSpec>,
363    /// Cancel and close selection view.
364    pub cancel: Option<KeybindingsSpec>,
365}
366
367/// Approval overlay keybindings.
368#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
369#[serde(deny_unknown_fields)]
370#[schemars(deny_unknown_fields)]
371pub struct TuiApprovalKeymap {
372    /// Open the full-screen approval details view.
373    pub open_fullscreen: Option<KeybindingsSpec>,
374    /// Open the thread that requested approval when shown from another thread.
375    pub open_thread: Option<KeybindingsSpec>,
376    /// Approve the primary option.
377    pub approve: Option<KeybindingsSpec>,
378    /// Approve for session when that option exists.
379    pub approve_for_session: Option<KeybindingsSpec>,
380    /// Approve with exec-policy prefix when that option exists.
381    pub approve_for_prefix: Option<KeybindingsSpec>,
382    /// Deny without providing follow-up guidance.
383    pub deny: Option<KeybindingsSpec>,
384    /// Decline and provide corrective guidance.
385    pub decline: Option<KeybindingsSpec>,
386    /// Cancel an elicitation request.
387    pub cancel: Option<KeybindingsSpec>,
388}
389
390/// Raw keymap configuration from `[tui.keymap]`.
391///
392/// Each context contains action-level overrides. Missing actions inherit from
393/// built-in defaults, and selected chat/composer actions can fall back
394/// through `global` during runtime resolution.
395///
396/// This type is intentionally a persistence shape, not the structure used by
397/// input handlers. Runtime consumers should resolve it into
398/// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key
399/// validation are applied consistently.
400#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
401#[serde(deny_unknown_fields)]
402#[schemars(deny_unknown_fields)]
403pub struct TuiKeymap {
404    #[serde(default)]
405    pub global: TuiGlobalKeymap,
406    #[serde(default)]
407    pub chat: TuiChatKeymap,
408    #[serde(default)]
409    pub composer: TuiComposerKeymap,
410    #[serde(default)]
411    pub editor: TuiEditorKeymap,
412    #[serde(default)]
413    pub vim_normal: TuiVimNormalKeymap,
414    #[serde(default)]
415    pub vim_operator: TuiVimOperatorKeymap,
416    #[serde(default)]
417    pub vim_text_object: TuiVimTextObjectKeymap,
418    #[serde(default)]
419    pub pager: TuiPagerKeymap,
420    #[serde(default)]
421    pub list: TuiListKeymap,
422    #[serde(default)]
423    pub approval: TuiApprovalKeymap,
424}
425
426/// Normalize one user-entered key spec into canonical storage format.
427///
428/// The output always orders modifiers as `ctrl-alt-shift-<key>` when present
429/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`).
430/// Inputs that cannot be represented unambiguously are rejected.
431///
432/// Normalization happens at config-deserialization time so downstream runtime
433/// code only has to parse one spelling for each key. Callers should not bypass
434/// this function when accepting user-authored key specs, or otherwise equivalent
435/// keys can fail to compare equal in tests, UI hints, and duplicate detection.
436fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
437    let lower = raw.trim().to_ascii_lowercase();
438    if lower.is_empty() {
439        return Err(
440            "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\
441See the Codex keymap documentation for supported actions and examples."
442                .to_string(),
443        );
444    }
445
446    let segments: Vec<&str> = lower
447        .split('-')
448        .filter(|segment| !segment.is_empty())
449        .collect();
450    if segments.is_empty() {
451        return Err(format!(
452            "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`."
453        ));
454    }
455
456    let mut modifiers =
457        BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]);
458    let mut key_segments = Vec::new();
459    let mut saw_key = false;
460
461    for segment in segments {
462        let canonical_mod = match segment {
463            "ctrl" | "control" => Some("ctrl"),
464            "alt" | "option" => Some("alt"),
465            "shift" => Some("shift"),
466            _ => None,
467        };
468
469        if !saw_key && let Some(modifier) = canonical_mod {
470            if modifiers.get(modifier).copied().unwrap_or(false) {
471                return Err(format!(
472                    "duplicate modifier in keybinding `{raw}`. Use each modifier at most once."
473                ));
474            }
475            modifiers.insert(modifier, true);
476            continue;
477        }
478
479        saw_key = true;
480        key_segments.push(segment);
481    }
482
483    if key_segments.is_empty() {
484        return Err(format!(
485            "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`."
486        ));
487    }
488
489    if key_segments
490        .iter()
491        .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift"))
492    {
493        return Err(format!(
494            "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)."
495        ));
496    }
497
498    let key = normalize_key_name(&key_segments.join("-"), raw)?;
499    let mut normalized = Vec::new();
500    if modifiers.get("ctrl").copied().unwrap_or(false) {
501        normalized.push("ctrl".to_string());
502    }
503    if modifiers.get("alt").copied().unwrap_or(false) {
504        normalized.push("alt".to_string());
505    }
506    if modifiers.get("shift").copied().unwrap_or(false) {
507        normalized.push("shift".to_string());
508    }
509    normalized.push(key);
510    Ok(normalized.join("-"))
511}
512
513/// Normalize and validate one key name segment.
514///
515/// This accepts a constrained key vocabulary to keep runtime parser behavior
516/// deterministic across platforms.
517fn normalize_key_name(key: &str, original: &str) -> Result<String, String> {
518    let alias = match key {
519        "escape" => "esc",
520        "return" => "enter",
521        "spacebar" => "space",
522        "pgup" | "pageup" => "page-up",
523        "pgdn" | "pagedown" => "page-down",
524        "del" => "delete",
525        other => other,
526    };
527
528    if alias.len() == 1 {
529        let ch = alias.chars().next().unwrap_or_default();
530        if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' {
531            return Ok(alias.to_string());
532        }
533    }
534
535    if matches!(
536        alias,
537        "enter"
538            | "tab"
539            | "backspace"
540            | "esc"
541            | "delete"
542            | "up"
543            | "down"
544            | "left"
545            | "right"
546            | "home"
547            | "end"
548            | "page-up"
549            | "page-down"
550            | "space"
551            | "minus"
552    ) {
553        return Ok(alias.to_string());
554    }
555
556    if let Some(number) = alias.strip_prefix('f')
557        && let Ok(number) = number.parse::<u8>()
558        && (1..=MAX_FUNCTION_KEY).contains(&number)
559    {
560        return Ok(alias.to_string());
561    }
562
563    Err(format!(
564        "unknown key `{key}` in keybinding `{original}`. \
565Use a printable character (for example `a`), function keys (`f1`-`f{MAX_FUNCTION_KEY}`), \
566or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\
567See the Codex keymap documentation for supported actions and examples."
568    ))
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use pretty_assertions::assert_eq;
575
576    #[test]
577    fn misplaced_action_at_keymap_root_is_rejected() {
578        // Actions placed directly under [tui.keymap] instead of a context
579        // sub-table (e.g. [tui.keymap.global]) must produce a parse error,
580        // not be silently ignored.
581        let toml_input = r#"
582            open_transcript = "ctrl-s"
583        "#;
584        let result = toml::from_str::<TuiKeymap>(toml_input);
585        assert!(
586            result.is_err(),
587            "expected error for action at keymap root, got: {result:?}"
588        );
589    }
590
591    #[test]
592    fn misspelled_action_under_context_is_rejected() {
593        let toml_input = r#"
594            [global]
595            open_transcrip = "ctrl-x"
596        "#;
597        let err = toml::from_str::<TuiKeymap>(toml_input)
598            .expect_err("expected unknown action under context");
599        assert!(
600            err.to_string().contains("open_transcrip"),
601            "expected error to mention misspelled field, got: {err}"
602        );
603    }
604
605    #[test]
606    fn misspelled_vim_text_object_action_is_rejected() {
607        let toml_input = r#"
608            [vim_text_object]
609            double_quotes = "shift-quote"
610        "#;
611        let err = toml::from_str::<TuiKeymap>(toml_input)
612            .expect_err("expected unknown vim text object action");
613        assert!(
614            err.to_string().contains("double_quotes"),
615            "expected error to mention misspelled field, got: {err}"
616        );
617    }
618
619    #[test]
620    fn removed_backtrack_actions_are_rejected() {
621        for (context, action) in [
622            ("global", "edit_previous_message"),
623            ("global", "confirm_edit_previous_message"),
624            ("chat", "edit_previous_message"),
625            ("chat", "confirm_edit_previous_message"),
626            ("pager", "edit_previous_message"),
627            ("pager", "edit_next_message"),
628            ("pager", "confirm_edit_message"),
629        ] {
630            let toml_input = format!(
631                r#"
632                [{context}]
633                {action} = "ctrl-x"
634                "#
635            );
636            let err = toml::from_str::<TuiKeymap>(&toml_input)
637                .expect_err("expected removed backtrack action to be rejected");
638            assert!(
639                err.to_string().contains(action),
640                "expected error to mention removed field {action}, got: {err}"
641            );
642        }
643    }
644
645    #[test]
646    fn action_under_global_context_is_accepted() {
647        let toml_input = r#"
648            [global]
649            open_transcript = "ctrl-s"
650        "#;
651        let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config");
652        assert!(keymap.global.open_transcript.is_some());
653    }
654
655    #[test]
656    fn minus_bindings_under_global_context_are_accepted() {
657        for (spec, expected) in [
658            (
659                "minus",
660                KeybindingsSpec::One(KeybindingSpec("minus".to_string())),
661            ),
662            (
663                "alt-minus",
664                KeybindingsSpec::One(KeybindingSpec("alt-minus".to_string())),
665            ),
666        ] {
667            let toml_input = format!(
668                r#"
669                [global]
670                open_transcript = "{spec}"
671                "#
672            );
673            let keymap: TuiKeymap = toml::from_str(&toml_input).expect("valid config");
674            let mut expected_keymap = TuiKeymap::default();
675            expected_keymap.global.open_transcript = Some(expected);
676
677            assert_eq!(keymap, expected_keymap);
678        }
679    }
680
681    #[test]
682    fn function_keys_through_f24_are_accepted() {
683        assert_eq!(normalize_keybinding_spec("F13"), Ok("f13".to_string()));
684        assert_eq!(normalize_keybinding_spec("f24"), Ok("f24".to_string()));
685        assert!(normalize_keybinding_spec("f25").is_err());
686    }
687}