mirror of
https://github.com/openai/codex.git
synced 2026-05-19 02:33:10 +00:00
## Why Picker-style UI in the TUI has accumulated a mix of hardcoded navigation keys. Some lists supported page movement, some did not; some accepted Vim-like keys, while others only accepted arrows; and tabbed or horizontally adjustable pickers had no shared keymap action for left/right movement. This PR makes picker/list navigation consistent and configurable so users can rely on the same defaults across the TUI. ## What Changed - Adds shared list keymap actions for: - vertical movement: `move_up`, `move_down` - horizontal movement: `move_left`, `move_right` - paging and jumps: `page_up`, `page_down`, `jump_top`, `jump_bottom` - Adds defaults: - Up/down: arrows, `Ctrl+P/N`, `Ctrl+K/J`, and plain `k/j` where text input is not active - Page up/down: `PageUp/PageDown` and `Ctrl+B/F` - First/last: `Home/End` - Left/right: `Left/Right` and `Ctrl+H/L` - Wires the shared list keymap through picker and list surfaces including session resume, multi-select, tabbed selection lists, settings-style lists, app-link selection, MCP elicitation, request-user-input, and the OSS selection wizard. - Keeps search behavior intact by reserving printable characters for query text in searchable pickers. - Updates keymap setup actions, config schema, snapshots, and focused coverage for the new list actions. ## How to Test 1. Start Codex from this branch and open the session picker, for example with an existing session history. 2. In the session list, verify that `Ctrl+J/K` moves the selection down/up. 3. Verify that `Ctrl+F/B` pages down/up and `Home/End` jumps to the first/last visible session. 4. Type printable search text such as `j` or `k` and confirm it updates the query instead of navigating. 5. Focus a picker control that changes values horizontally, such as a session picker toolbar control, and verify `Ctrl+H/L` changes the focused value like left/right arrows. Targeted tests run: - `cargo test -p codex-tui keymap::tests::` - `cargo test -p codex-tui keymap_setup::tests::` - `cargo test -p codex-tui horizontal_list_keys` - `cargo test -p codex-tui page_and_jump_navigation_use_list_keymap` - `cargo test -p codex-tui ctrl_h_l_move_provider_selection` - `cargo test -p codex-tui scroll_state::tests` - `cargo test -p codex-tui switching_tabs_changes_visible_items_and_clears_search` - `cargo test -p codex-tui toggle_sort_key_reloads_with_new_sort` Also ran `just write-config-schema`, `just fmt`, `just fix -p codex-tui`, `just argument-comment-lint`, and `git diff --check`. Note: `cargo test -p codex-tui` was attempted and still aborts in the pre-existing `tests::fork_last_filters_latest_session_by_cwd_unless_show_all` stack overflow, which is unrelated to this branch.
625 lines
23 KiB
Rust
625 lines
23 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;
|
|
|
|
/// 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 {
|
|
/// 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 from cursor to end of line (`D`).
|
|
pub delete_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>,
|
|
/// 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>,
|
|
/// Cancel the pending operator and return to normal mode.
|
|
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 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..=12).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`-`f12`), \
|
|
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 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);
|
|
}
|
|
}
|
|
}
|