1use schemars::JsonSchema;
20use serde::Deserialize;
21use serde::Deserializer;
22use serde::Serialize;
23use serde::de::Error as SerdeError;
24use std::collections::BTreeMap;
25
26pub const MAX_FUNCTION_KEY: u8 = 24;
28
29#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
38#[serde(transparent)]
39pub struct KeybindingSpec(#[schemars(with = "String")] pub String);
40
41impl KeybindingSpec {
42 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#[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 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
91#[serde(deny_unknown_fields)]
92#[schemars(deny_unknown_fields)]
93pub struct TuiGlobalKeymap {
94 pub open_transcript: Option<KeybindingsSpec>,
96 pub open_external_editor: Option<KeybindingsSpec>,
98 pub copy: Option<KeybindingsSpec>,
100 pub clear_terminal: Option<KeybindingsSpec>,
102 pub submit: Option<KeybindingsSpec>,
104 pub queue: Option<KeybindingsSpec>,
106 pub toggle_shortcuts: Option<KeybindingsSpec>,
108 pub toggle_vim_mode: Option<KeybindingsSpec>,
110 pub toggle_fast_mode: Option<KeybindingsSpec>,
112 pub toggle_raw_output: Option<KeybindingsSpec>,
114}
115
116#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
118#[serde(deny_unknown_fields)]
119#[schemars(deny_unknown_fields)]
120pub struct TuiChatKeymap {
121 pub interrupt_turn: Option<KeybindingsSpec>,
123 pub decrease_reasoning_effort: Option<KeybindingsSpec>,
125 pub increase_reasoning_effort: Option<KeybindingsSpec>,
127 pub edit_queued_message: Option<KeybindingsSpec>,
129}
130
131#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
133#[serde(deny_unknown_fields)]
134#[schemars(deny_unknown_fields)]
135pub struct TuiComposerKeymap {
136 pub submit: Option<KeybindingsSpec>,
138 pub queue: Option<KeybindingsSpec>,
140 pub toggle_shortcuts: Option<KeybindingsSpec>,
142 pub history_search_previous: Option<KeybindingsSpec>,
144 pub history_search_next: Option<KeybindingsSpec>,
146}
147
148#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
150#[serde(deny_unknown_fields)]
151#[schemars(deny_unknown_fields)]
152pub struct TuiEditorKeymap {
153 pub insert_newline: Option<KeybindingsSpec>,
155 pub move_left: Option<KeybindingsSpec>,
157 pub move_right: Option<KeybindingsSpec>,
159 pub move_up: Option<KeybindingsSpec>,
161 pub move_down: Option<KeybindingsSpec>,
163 pub move_word_left: Option<KeybindingsSpec>,
165 pub move_word_right: Option<KeybindingsSpec>,
167 pub move_line_start: Option<KeybindingsSpec>,
169 pub move_line_end: Option<KeybindingsSpec>,
171 pub delete_backward: Option<KeybindingsSpec>,
173 pub delete_forward: Option<KeybindingsSpec>,
175 pub delete_backward_word: Option<KeybindingsSpec>,
177 pub delete_forward_word: Option<KeybindingsSpec>,
179 pub kill_line_start: Option<KeybindingsSpec>,
181 pub kill_whole_line: Option<KeybindingsSpec>,
183 pub kill_line_end: Option<KeybindingsSpec>,
185 pub yank: Option<KeybindingsSpec>,
187}
188
189#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
195#[schemars(deny_unknown_fields)]
196pub struct TuiVimNormalKeymap {
197 pub enter_insert: Option<KeybindingsSpec>,
199 pub append_after_cursor: Option<KeybindingsSpec>,
201 pub append_line_end: Option<KeybindingsSpec>,
203 pub insert_line_start: Option<KeybindingsSpec>,
205 pub open_line_below: Option<KeybindingsSpec>,
207 pub open_line_above: Option<KeybindingsSpec>,
209 pub move_left: Option<KeybindingsSpec>,
211 pub move_right: Option<KeybindingsSpec>,
213 pub move_up: Option<KeybindingsSpec>,
215 pub move_down: Option<KeybindingsSpec>,
217 pub move_word_forward: Option<KeybindingsSpec>,
219 pub move_word_backward: Option<KeybindingsSpec>,
221 pub move_word_end: Option<KeybindingsSpec>,
223 pub move_line_start: Option<KeybindingsSpec>,
225 pub move_line_end: Option<KeybindingsSpec>,
227 pub delete_char: Option<KeybindingsSpec>,
229 pub substitute_char: Option<KeybindingsSpec>,
231 pub delete_to_line_end: Option<KeybindingsSpec>,
233 pub change_to_line_end: Option<KeybindingsSpec>,
235 pub yank_line: Option<KeybindingsSpec>,
237 pub paste_after: Option<KeybindingsSpec>,
239 pub start_delete_operator: Option<KeybindingsSpec>,
241 pub start_yank_operator: Option<KeybindingsSpec>,
243 pub start_change_operator: Option<KeybindingsSpec>,
245 pub cancel_operator: Option<KeybindingsSpec>,
247}
248
249#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
256#[schemars(deny_unknown_fields)]
257pub struct TuiVimOperatorKeymap {
258 pub delete_line: Option<KeybindingsSpec>,
260 pub yank_line: Option<KeybindingsSpec>,
262 pub motion_left: Option<KeybindingsSpec>,
264 pub motion_right: Option<KeybindingsSpec>,
266 pub motion_up: Option<KeybindingsSpec>,
268 pub motion_down: Option<KeybindingsSpec>,
270 pub motion_word_forward: Option<KeybindingsSpec>,
272 pub motion_word_backward: Option<KeybindingsSpec>,
274 pub motion_word_end: Option<KeybindingsSpec>,
276 pub motion_line_start: Option<KeybindingsSpec>,
278 pub motion_line_end: Option<KeybindingsSpec>,
280 pub select_inner_text_object: Option<KeybindingsSpec>,
282 pub select_around_text_object: Option<KeybindingsSpec>,
284 pub cancel: Option<KeybindingsSpec>,
286}
287
288#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
290#[serde(deny_unknown_fields)]
291#[schemars(deny_unknown_fields)]
292pub struct TuiVimTextObjectKeymap {
293 pub word: Option<KeybindingsSpec>,
295 pub big_word: Option<KeybindingsSpec>,
297 pub parentheses: Option<KeybindingsSpec>,
299 pub brackets: Option<KeybindingsSpec>,
301 pub braces: Option<KeybindingsSpec>,
303 pub double_quote: Option<KeybindingsSpec>,
305 pub single_quote: Option<KeybindingsSpec>,
307 pub backtick: Option<KeybindingsSpec>,
309 pub cancel: Option<KeybindingsSpec>,
311}
312
313#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
315#[serde(deny_unknown_fields)]
316#[schemars(deny_unknown_fields)]
317pub struct TuiPagerKeymap {
318 pub scroll_up: Option<KeybindingsSpec>,
320 pub scroll_down: Option<KeybindingsSpec>,
322 pub page_up: Option<KeybindingsSpec>,
324 pub page_down: Option<KeybindingsSpec>,
326 pub half_page_up: Option<KeybindingsSpec>,
328 pub half_page_down: Option<KeybindingsSpec>,
330 pub jump_top: Option<KeybindingsSpec>,
332 pub jump_bottom: Option<KeybindingsSpec>,
334 pub close: Option<KeybindingsSpec>,
336 pub close_transcript: Option<KeybindingsSpec>,
338}
339
340#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
342#[serde(deny_unknown_fields)]
343#[schemars(deny_unknown_fields)]
344pub struct TuiListKeymap {
345 pub move_up: Option<KeybindingsSpec>,
347 pub move_down: Option<KeybindingsSpec>,
349 pub move_left: Option<KeybindingsSpec>,
351 pub move_right: Option<KeybindingsSpec>,
353 pub page_up: Option<KeybindingsSpec>,
355 pub page_down: Option<KeybindingsSpec>,
357 pub jump_top: Option<KeybindingsSpec>,
359 pub jump_bottom: Option<KeybindingsSpec>,
361 pub accept: Option<KeybindingsSpec>,
363 pub cancel: Option<KeybindingsSpec>,
365}
366
367#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
369#[serde(deny_unknown_fields)]
370#[schemars(deny_unknown_fields)]
371pub struct TuiApprovalKeymap {
372 pub open_fullscreen: Option<KeybindingsSpec>,
374 pub open_thread: Option<KeybindingsSpec>,
376 pub approve: Option<KeybindingsSpec>,
378 pub approve_for_session: Option<KeybindingsSpec>,
380 pub approve_for_prefix: Option<KeybindingsSpec>,
382 pub deny: Option<KeybindingsSpec>,
384 pub decline: Option<KeybindingsSpec>,
386 pub cancel: Option<KeybindingsSpec>,
388}
389
390#[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
426fn 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
513fn 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 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}