mirror of
https://github.com/openai/codex.git
synced 2026-05-23 20:44:50 +00:00
## Why Granular copy is particularly difficult with the current output. Part of it was solved with the introduction of the `/copy` command but when you only need to copy parts of a response, you still encounter some issues: - When you copy a paragraph, the result is a sequence of separate lines instead of one correctly joined paragraph. - When a word wraps, part of it stays on the original line and the rest appears at the start of the next line. - When you copy a long command, extra line breaks are often inserted, and command arguments can be split across multiple lines. https://github.com/user-attachments/assets/0ef85c84-9363-4aad-b43a-15fce062a443 ## Solution Now that we own the scrollback and we re-create it when we resize, we have the opportunity of toggling between the raw text and the rich text we see today. - Add TUI raw scrollback mode with `tui.raw_output_mode`, `/raw [on|off]`, and the configurable `tui.keymap.global.toggle_raw_output` action. - Render transcript cells through rich/raw-aware paths so raw mode preserves source text and lets the terminal soft-wrap selection-friendly output. - Bind raw-mode toggle to `alt-r` by default, with the keybinding path toggling silently while `/raw` continues to emit confirmation messages. ## Related Issues Likely addressed by raw mode: - #12200: clean copy for multiline and soft-wrapped output. Raw mode removes Codex-inserted wrapping/indentation and lets the terminal soft-wrap logical lines. - #9252: command suggestions gain unwanted leading spaces when copied. Raw mode renders transcript text without the rich-mode left padding/gutter. - #8258: prompt output is hard to copy because of leading indentation. Raw mode renders user/source-backed transcript text without that decorative indentation. Partially or conditionally addressed: - #2880: copy/export message as Markdown. Raw mode exposes raw Markdown for terminal selection, but this PR does not add a dedicated export/copy-message command. - #19820: mouse drag selection + copy in the TUI. Raw mode improves terminal-native selection of output/history text, but this PR does not implement in-TUI mouse selection, highlighting, auto-copy, or composer selection. - #18979: copied content is divided into two parts. This should improve cases caused by Codex-inserted wraps/padding in rendered output; if the report is about pasting into the composer/input path, that remains outside this PR. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-tui` - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo test -p codex-tui raw_output_mode_can_change_without_inserting_notice -- --nocapture` - `cargo test -p codex-tui raw_slash_command_toggles_and_accepts_on_off_args -- --nocapture` - `cargo test -p codex-tui raw_output_toggle -- --nocapture` - `git diff --check` - `cargo insta pending-snapshots`
585 lines
22 KiB
Rust
585 lines
22 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>,
|
|
/// 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"
|
|
) {
|
|
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.\n\
|
|
See the Codex keymap documentation for supported actions and examples."
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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());
|
|
}
|
|
}
|