Files
codex/codex-rs/config/src/tui_keymap.rs
Felipe Coury 3d517fbd00 feat(tui): standardize picker navigation keys (#22347)
## 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.
2026-05-13 15:33:27 +00:00

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);
}
}
}