typeduck-codex-extension-items 0.5.0

Support package for the standalone Codex Web runtime (codex-config)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
//! TUI keymap config schema and canonical key-spec normalization.
//!
//! This module defines the on-disk `[tui.keymap]` contract used by
//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical
//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`.
//!
//! Responsibilities:
//!
//! 1. Define strongly typed config contexts/actions with unknown-field
//!    rejection.
//! 2. Normalize accepted key aliases into canonical names.
//! 3. Reject malformed bindings early with user-facing diagnostics.
//!
//! Non-responsibilities:
//!
//! 1. Dispatch precedence and conflict validation.
//! 2. Input event matching at runtime.

use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use std::collections::BTreeMap;

/// Highest function key supported by portable TUI keymap configuration.
pub const MAX_FUNCTION_KEY: u8 = 24;

/// Normalized string representation of a single key event (for example `ctrl-a`).
///
/// The parser accepts a small alias set (for example `escape` -> `esc`,
/// `pageup` -> `page-up`) and stores the canonical form.
///
/// This deliberately represents one terminal key event, not a sequence of
/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding
/// multi-step chords would require a separate runtime state machine.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
pub struct KeybindingSpec(#[schemars(with = "String")] pub String);

impl KeybindingSpec {
    /// Returns the canonical key-spec string (for example `ctrl-a`).
    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }
}

impl<'de> Deserialize<'de> for KeybindingSpec {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let raw = String::deserialize(deserializer)?;
        let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?;
        Ok(Self(normalized))
    }
}

/// One action binding value in config.
///
/// This accepts either:
///
/// 1. A single key spec string (`"ctrl-a"`).
/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`).
///
/// An empty list explicitly unbinds the action in that scope. Because an
/// explicit empty list is still a configured value, runtime resolution must not
/// fall through to global or built-in defaults for that action.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum KeybindingsSpec {
    One(KeybindingSpec),
    Many(Vec<KeybindingSpec>),
}

impl KeybindingsSpec {
    /// Returns all configured key specs for one action in declaration order.
    ///
    /// Callers should preserve this ordering when deriving UI hints so the
    /// first binding remains the primary affordance shown to users.
    pub fn specs(&self) -> Vec<&KeybindingSpec> {
        match self {
            Self::One(spec) => vec![spec],
            Self::Many(specs) => specs.iter().collect(),
        }
    }
}

/// Global keybindings. These are used when a context does not define an override.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiGlobalKeymap {
    /// Open the transcript overlay.
    pub open_transcript: Option<KeybindingsSpec>,
    /// Open the external editor for the current draft.
    pub open_external_editor: Option<KeybindingsSpec>,
    /// Copy the last agent response to the clipboard.
    pub copy: Option<KeybindingsSpec>,
    /// Clear the terminal UI.
    pub clear_terminal: Option<KeybindingsSpec>,
    /// Submit the current composer draft.
    pub submit: Option<KeybindingsSpec>,
    /// Queue the current composer draft while a task is running.
    pub queue: Option<KeybindingsSpec>,
    /// Toggle the composer shortcut overlay.
    pub toggle_shortcuts: Option<KeybindingsSpec>,
    /// Toggle Vim mode for the composer input.
    pub toggle_vim_mode: Option<KeybindingsSpec>,
    /// Toggle Fast mode.
    pub toggle_fast_mode: Option<KeybindingsSpec>,
    /// Toggle raw scrollback mode for copy-friendly transcript selection.
    pub toggle_raw_output: Option<KeybindingsSpec>,
}

/// Chat context keybindings.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiChatKeymap {
    /// Interrupt the active turn.
    pub interrupt_turn: Option<KeybindingsSpec>,
    /// Decrease the active reasoning effort.
    pub decrease_reasoning_effort: Option<KeybindingsSpec>,
    /// Increase the active reasoning effort.
    pub increase_reasoning_effort: Option<KeybindingsSpec>,
    /// Edit the most recently queued message.
    pub edit_queued_message: Option<KeybindingsSpec>,
}

/// Composer context keybindings. These override corresponding `global` actions.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiComposerKeymap {
    /// Submit the current composer draft.
    pub submit: Option<KeybindingsSpec>,
    /// Queue the current composer draft while a task is running.
    pub queue: Option<KeybindingsSpec>,
    /// Toggle the composer shortcut overlay.
    pub toggle_shortcuts: Option<KeybindingsSpec>,
    /// Open reverse history search or move to the previous match.
    pub history_search_previous: Option<KeybindingsSpec>,
    /// Move to the next match in reverse history search.
    pub history_search_next: Option<KeybindingsSpec>,
}

/// Editor context keybindings for text editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiEditorKeymap {
    /// Insert a newline in the editor.
    pub insert_newline: Option<KeybindingsSpec>,
    /// Move cursor left by one grapheme.
    pub move_left: Option<KeybindingsSpec>,
    /// Move cursor right by one grapheme.
    pub move_right: Option<KeybindingsSpec>,
    /// Move cursor up one visual line.
    pub move_up: Option<KeybindingsSpec>,
    /// Move cursor down one visual line.
    pub move_down: Option<KeybindingsSpec>,
    /// Move cursor to beginning of previous word.
    pub move_word_left: Option<KeybindingsSpec>,
    /// Move cursor to end of next word.
    pub move_word_right: Option<KeybindingsSpec>,
    /// Move cursor to beginning of line.
    pub move_line_start: Option<KeybindingsSpec>,
    /// Move cursor to end of line.
    pub move_line_end: Option<KeybindingsSpec>,
    /// Delete one grapheme to the left.
    pub delete_backward: Option<KeybindingsSpec>,
    /// Delete one grapheme to the right.
    pub delete_forward: Option<KeybindingsSpec>,
    /// Delete the previous word.
    pub delete_backward_word: Option<KeybindingsSpec>,
    /// Delete the next word.
    pub delete_forward_word: Option<KeybindingsSpec>,
    /// Kill text from cursor to line start.
    pub kill_line_start: Option<KeybindingsSpec>,
    /// Kill the current line.
    pub kill_whole_line: Option<KeybindingsSpec>,
    /// Kill text from cursor to line end.
    pub kill_line_end: Option<KeybindingsSpec>,
    /// Yank the kill buffer.
    pub yank: Option<KeybindingsSpec>,
}

/// Vim normal-mode keybindings for modal editing inside text areas.
///
/// Actions that use uppercase letters (like `A` for append-line-end) should
/// be specified as `shift-a` in config; the runtime matcher handles
/// cross-terminal shift-reporting differences automatically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimNormalKeymap {
    /// Enter insert mode at cursor (`i`).
    pub enter_insert: Option<KeybindingsSpec>,
    /// Enter insert mode after cursor (`a`).
    pub append_after_cursor: Option<KeybindingsSpec>,
    /// Enter insert mode at end of line (`A`).
    pub append_line_end: Option<KeybindingsSpec>,
    /// Enter insert mode at first non-blank of line (`I`).
    pub insert_line_start: Option<KeybindingsSpec>,
    /// Open a new line below and enter insert mode (`o`).
    pub open_line_below: Option<KeybindingsSpec>,
    /// Open a new line above and enter insert mode (`O`).
    pub open_line_above: Option<KeybindingsSpec>,
    /// Move cursor left (`h`).
    pub move_left: Option<KeybindingsSpec>,
    /// Move cursor right (`l`).
    pub move_right: Option<KeybindingsSpec>,
    /// Move cursor up (`k`), or recall older composer history at history boundaries.
    pub move_up: Option<KeybindingsSpec>,
    /// Move cursor down (`j`), or recall newer composer history at history boundaries.
    pub move_down: Option<KeybindingsSpec>,
    /// Move cursor to start of next word (`w`).
    pub move_word_forward: Option<KeybindingsSpec>,
    /// Move cursor to start of previous word (`b`).
    pub move_word_backward: Option<KeybindingsSpec>,
    /// Move cursor to end of current/next word (`e`).
    pub move_word_end: Option<KeybindingsSpec>,
    /// Move cursor to start of line (`0`).
    pub move_line_start: Option<KeybindingsSpec>,
    /// Move cursor to end of line (`$`).
    pub move_line_end: Option<KeybindingsSpec>,
    /// Delete character under cursor (`x`).
    pub delete_char: Option<KeybindingsSpec>,
    /// Delete character under cursor and enter insert mode (`s`).
    pub substitute_char: Option<KeybindingsSpec>,
    /// Delete from cursor to end of line (`D`).
    pub delete_to_line_end: Option<KeybindingsSpec>,
    /// Change from cursor to end of line and enter insert mode (`C`).
    pub change_to_line_end: Option<KeybindingsSpec>,
    /// Yank the entire line (`Y`).
    pub yank_line: Option<KeybindingsSpec>,
    /// Paste after cursor (`p`).
    pub paste_after: Option<KeybindingsSpec>,
    /// Begin delete operator; next key selects motion (`d`).
    pub start_delete_operator: Option<KeybindingsSpec>,
    /// Begin yank operator; next key selects motion (`y`).
    pub start_yank_operator: Option<KeybindingsSpec>,
    /// Begin change operator; next keys select a text object.
    pub start_change_operator: Option<KeybindingsSpec>,
    /// Cancel a pending operator and return to normal mode.
    pub cancel_operator: Option<KeybindingsSpec>,
}

/// Vim operator-pending keybindings for modal editing inside text areas.
///
/// This context is active only while waiting for a motion after `d` or `y`.
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
/// `Esc` cancels the pending operator and returns to normal mode without
/// modifying text.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimOperatorKeymap {
    /// Repeat delete operator to delete the whole line (`dd`).
    pub delete_line: Option<KeybindingsSpec>,
    /// Repeat yank operator to yank the whole line (`yy`).
    pub yank_line: Option<KeybindingsSpec>,
    /// Motion: left (`h`).
    pub motion_left: Option<KeybindingsSpec>,
    /// Motion: right (`l`).
    pub motion_right: Option<KeybindingsSpec>,
    /// Motion: up one line (`k`).
    pub motion_up: Option<KeybindingsSpec>,
    /// Motion: down one line (`j`).
    pub motion_down: Option<KeybindingsSpec>,
    /// Motion: to start of next word (`w`).
    pub motion_word_forward: Option<KeybindingsSpec>,
    /// Motion: to start of previous word (`b`).
    pub motion_word_backward: Option<KeybindingsSpec>,
    /// Motion: to end of current/next word (`e`).
    pub motion_word_end: Option<KeybindingsSpec>,
    /// Motion: to start of line (`0`).
    pub motion_line_start: Option<KeybindingsSpec>,
    /// Motion: to end of line (`$`).
    pub motion_line_end: Option<KeybindingsSpec>,
    /// Select an inner text object after an operator.
    pub select_inner_text_object: Option<KeybindingsSpec>,
    /// Select an around text object after an operator.
    pub select_around_text_object: Option<KeybindingsSpec>,
    /// Cancel the pending operator and return to normal mode.
    pub cancel: Option<KeybindingsSpec>,
}

/// Vim text-object keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimTextObjectKeymap {
    /// Text object: word.
    pub word: Option<KeybindingsSpec>,
    /// Text object: whitespace-delimited WORD.
    pub big_word: Option<KeybindingsSpec>,
    /// Text object: parentheses.
    pub parentheses: Option<KeybindingsSpec>,
    /// Text object: brackets.
    pub brackets: Option<KeybindingsSpec>,
    /// Text object: braces.
    pub braces: Option<KeybindingsSpec>,
    /// Text object: double quotes.
    pub double_quote: Option<KeybindingsSpec>,
    /// Text object: single quotes.
    pub single_quote: Option<KeybindingsSpec>,
    /// Text object: backticks.
    pub backtick: Option<KeybindingsSpec>,
    /// Cancel the pending text-object command.
    pub cancel: Option<KeybindingsSpec>,
}

/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiPagerKeymap {
    /// Scroll up by one row.
    pub scroll_up: Option<KeybindingsSpec>,
    /// Scroll down by one row.
    pub scroll_down: Option<KeybindingsSpec>,
    /// Scroll up by one page.
    pub page_up: Option<KeybindingsSpec>,
    /// Scroll down by one page.
    pub page_down: Option<KeybindingsSpec>,
    /// Scroll up by half a page.
    pub half_page_up: Option<KeybindingsSpec>,
    /// Scroll down by half a page.
    pub half_page_down: Option<KeybindingsSpec>,
    /// Jump to the beginning.
    pub jump_top: Option<KeybindingsSpec>,
    /// Jump to the end.
    pub jump_bottom: Option<KeybindingsSpec>,
    /// Close the pager overlay.
    pub close: Option<KeybindingsSpec>,
    /// Close the transcript overlay via its dedicated toggle key.
    pub close_transcript: Option<KeybindingsSpec>,
}

/// List selection context keybindings for popup-style selectable lists.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiListKeymap {
    /// Move list selection up.
    pub move_up: Option<KeybindingsSpec>,
    /// Move list selection down.
    pub move_down: Option<KeybindingsSpec>,
    /// Move horizontally left in list pickers that support horizontal actions.
    pub move_left: Option<KeybindingsSpec>,
    /// Move horizontally right in list pickers that support horizontal actions.
    pub move_right: Option<KeybindingsSpec>,
    /// Move list selection up by one page.
    pub page_up: Option<KeybindingsSpec>,
    /// Move list selection down by one page.
    pub page_down: Option<KeybindingsSpec>,
    /// Jump to the first list item.
    pub jump_top: Option<KeybindingsSpec>,
    /// Jump to the last list item.
    pub jump_bottom: Option<KeybindingsSpec>,
    /// Accept current selection.
    pub accept: Option<KeybindingsSpec>,
    /// Cancel and close selection view.
    pub cancel: Option<KeybindingsSpec>,
}

/// Approval overlay keybindings.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiApprovalKeymap {
    /// Open the full-screen approval details view.
    pub open_fullscreen: Option<KeybindingsSpec>,
    /// Open the thread that requested approval when shown from another thread.
    pub open_thread: Option<KeybindingsSpec>,
    /// Approve the primary option.
    pub approve: Option<KeybindingsSpec>,
    /// Approve for session when that option exists.
    pub approve_for_session: Option<KeybindingsSpec>,
    /// Approve with exec-policy prefix when that option exists.
    pub approve_for_prefix: Option<KeybindingsSpec>,
    /// Deny without providing follow-up guidance.
    pub deny: Option<KeybindingsSpec>,
    /// Decline and provide corrective guidance.
    pub decline: Option<KeybindingsSpec>,
    /// Cancel an elicitation request.
    pub cancel: Option<KeybindingsSpec>,
}

/// Raw keymap configuration from `[tui.keymap]`.
///
/// Each context contains action-level overrides. Missing actions inherit from
/// built-in defaults, and selected chat/composer actions can fall back
/// through `global` during runtime resolution.
///
/// This type is intentionally a persistence shape, not the structure used by
/// input handlers. Runtime consumers should resolve it into
/// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key
/// validation are applied consistently.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiKeymap {
    #[serde(default)]
    pub global: TuiGlobalKeymap,
    #[serde(default)]
    pub chat: TuiChatKeymap,
    #[serde(default)]
    pub composer: TuiComposerKeymap,
    #[serde(default)]
    pub editor: TuiEditorKeymap,
    #[serde(default)]
    pub vim_normal: TuiVimNormalKeymap,
    #[serde(default)]
    pub vim_operator: TuiVimOperatorKeymap,
    #[serde(default)]
    pub vim_text_object: TuiVimTextObjectKeymap,
    #[serde(default)]
    pub pager: TuiPagerKeymap,
    #[serde(default)]
    pub list: TuiListKeymap,
    #[serde(default)]
    pub approval: TuiApprovalKeymap,
}

/// Normalize one user-entered key spec into canonical storage format.
///
/// The output always orders modifiers as `ctrl-alt-shift-<key>` when present
/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`).
/// Inputs that cannot be represented unambiguously are rejected.
///
/// Normalization happens at config-deserialization time so downstream runtime
/// code only has to parse one spelling for each key. Callers should not bypass
/// this function when accepting user-authored key specs, or otherwise equivalent
/// keys can fail to compare equal in tests, UI hints, and duplicate detection.
fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
    let lower = raw.trim().to_ascii_lowercase();
    if lower.is_empty() {
        return Err(
            "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\
See the Codex keymap documentation for supported actions and examples."
                .to_string(),
        );
    }

    let segments: Vec<&str> = lower
        .split('-')
        .filter(|segment| !segment.is_empty())
        .collect();
    if segments.is_empty() {
        return Err(format!(
            "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`."
        ));
    }

    let mut modifiers =
        BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]);
    let mut key_segments = Vec::new();
    let mut saw_key = false;

    for segment in segments {
        let canonical_mod = match segment {
            "ctrl" | "control" => Some("ctrl"),
            "alt" | "option" => Some("alt"),
            "shift" => Some("shift"),
            _ => None,
        };

        if !saw_key && let Some(modifier) = canonical_mod {
            if modifiers.get(modifier).copied().unwrap_or(false) {
                return Err(format!(
                    "duplicate modifier in keybinding `{raw}`. Use each modifier at most once."
                ));
            }
            modifiers.insert(modifier, true);
            continue;
        }

        saw_key = true;
        key_segments.push(segment);
    }

    if key_segments.is_empty() {
        return Err(format!(
            "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`."
        ));
    }

    if key_segments
        .iter()
        .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift"))
    {
        return Err(format!(
            "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)."
        ));
    }

    let key = normalize_key_name(&key_segments.join("-"), raw)?;
    let mut normalized = Vec::new();
    if modifiers.get("ctrl").copied().unwrap_or(false) {
        normalized.push("ctrl".to_string());
    }
    if modifiers.get("alt").copied().unwrap_or(false) {
        normalized.push("alt".to_string());
    }
    if modifiers.get("shift").copied().unwrap_or(false) {
        normalized.push("shift".to_string());
    }
    normalized.push(key);
    Ok(normalized.join("-"))
}

/// Normalize and validate one key name segment.
///
/// This accepts a constrained key vocabulary to keep runtime parser behavior
/// deterministic across platforms.
fn normalize_key_name(key: &str, original: &str) -> Result<String, String> {
    let alias = match key {
        "escape" => "esc",
        "return" => "enter",
        "spacebar" => "space",
        "pgup" | "pageup" => "page-up",
        "pgdn" | "pagedown" => "page-down",
        "del" => "delete",
        other => other,
    };

    if alias.len() == 1 {
        let ch = alias.chars().next().unwrap_or_default();
        if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' {
            return Ok(alias.to_string());
        }
    }

    if matches!(
        alias,
        "enter"
            | "tab"
            | "backspace"
            | "esc"
            | "delete"
            | "up"
            | "down"
            | "left"
            | "right"
            | "home"
            | "end"
            | "page-up"
            | "page-down"
            | "space"
            | "minus"
    ) {
        return Ok(alias.to_string());
    }

    if let Some(number) = alias.strip_prefix('f')
        && let Ok(number) = number.parse::<u8>()
        && (1..=MAX_FUNCTION_KEY).contains(&number)
    {
        return Ok(alias.to_string());
    }

    Err(format!(
        "unknown key `{key}` in keybinding `{original}`. \
Use a printable character (for example `a`), function keys (`f1`-`f{MAX_FUNCTION_KEY}`), \
or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\
See the Codex keymap documentation for supported actions and examples."
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn misplaced_action_at_keymap_root_is_rejected() {
        // Actions placed directly under [tui.keymap] instead of a context
        // sub-table (e.g. [tui.keymap.global]) must produce a parse error,
        // not be silently ignored.
        let toml_input = r#"
            open_transcript = "ctrl-s"
        "#;
        let result = toml::from_str::<TuiKeymap>(toml_input);
        assert!(
            result.is_err(),
            "expected error for action at keymap root, got: {result:?}"
        );
    }

    #[test]
    fn misspelled_action_under_context_is_rejected() {
        let toml_input = r#"
            [global]
            open_transcrip = "ctrl-x"
        "#;
        let err = toml::from_str::<TuiKeymap>(toml_input)
            .expect_err("expected unknown action under context");
        assert!(
            err.to_string().contains("open_transcrip"),
            "expected error to mention misspelled field, got: {err}"
        );
    }

    #[test]
    fn misspelled_vim_text_object_action_is_rejected() {
        let toml_input = r#"
            [vim_text_object]
            double_quotes = "shift-quote"
        "#;
        let err = toml::from_str::<TuiKeymap>(toml_input)
            .expect_err("expected unknown vim text object action");
        assert!(
            err.to_string().contains("double_quotes"),
            "expected error to mention misspelled field, got: {err}"
        );
    }

    #[test]
    fn removed_backtrack_actions_are_rejected() {
        for (context, action) in [
            ("global", "edit_previous_message"),
            ("global", "confirm_edit_previous_message"),
            ("chat", "edit_previous_message"),
            ("chat", "confirm_edit_previous_message"),
            ("pager", "edit_previous_message"),
            ("pager", "edit_next_message"),
            ("pager", "confirm_edit_message"),
        ] {
            let toml_input = format!(
                r#"
                [{context}]
                {action} = "ctrl-x"
                "#
            );
            let err = toml::from_str::<TuiKeymap>(&toml_input)
                .expect_err("expected removed backtrack action to be rejected");
            assert!(
                err.to_string().contains(action),
                "expected error to mention removed field {action}, got: {err}"
            );
        }
    }

    #[test]
    fn action_under_global_context_is_accepted() {
        let toml_input = r#"
            [global]
            open_transcript = "ctrl-s"
        "#;
        let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config");
        assert!(keymap.global.open_transcript.is_some());
    }

    #[test]
    fn minus_bindings_under_global_context_are_accepted() {
        for (spec, expected) in [
            (
                "minus",
                KeybindingsSpec::One(KeybindingSpec("minus".to_string())),
            ),
            (
                "alt-minus",
                KeybindingsSpec::One(KeybindingSpec("alt-minus".to_string())),
            ),
        ] {
            let toml_input = format!(
                r#"
                [global]
                open_transcript = "{spec}"
                "#
            );
            let keymap: TuiKeymap = toml::from_str(&toml_input).expect("valid config");
            let mut expected_keymap = TuiKeymap::default();
            expected_keymap.global.open_transcript = Some(expected);

            assert_eq!(keymap, expected_keymap);
        }
    }

    #[test]
    fn function_keys_through_f24_are_accepted() {
        assert_eq!(normalize_keybinding_spec("F13"), Ok("f13".to_string()));
        assert_eq!(normalize_keybinding_spec("f24"), Ok("f24".to_string()));
        assert!(normalize_keybinding_spec("f25").is_err());
    }
}