mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
2f0726ad6d
## Why Closes #25006. `tui.keymap` currently rejects `F13` even though Codex's terminal event layer can report higher function keys. This prevents users from using common remappings such as Caps Lock to `F13`. ## What Changed - Define a shared portable upper bound of `F24` for stored TUI keybindings. - Accept `f13` through `f24` in config normalization and runtime parsing. - Allow `/keymap` capture to persist `F13` through `F24`. - Update the unsupported-function-key error and add boundary tests for `F13`, `F24`, and `F25`. ## How to Test 1. Add a binding such as: ```toml [tui.keymap.global] open_transcript = "f13" ``` 2. Start Codex and press the remapped `F13` key. 3. Confirm Codex loads the config without the previous `F1 through F12` error and the action runs. 4. Open `/keymap`, capture `F13` for an action, and confirm the saved binding is `f13`. 5. As a regression check, try to capture `F25` and confirm Codex reports that only `F1` through `F24` can be stored. Targeted tests: - `just test -p codex-config` - `just test -p codex-tui function_keys` Full `just test -p codex-tui` completed with 2,752 passing tests, 4 skipped tests, and two unrelated guardian feature-flag failures: - `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
688 lines
26 KiB
Rust
688 lines
26 KiB
Rust
//! 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());
|
|
}
|
|
}
|