mirror of
https://github.com/openai/codex.git
synced 2026-08-27 13:49:27 +00:00
## What changed - Add unbound `previous_permission_mode` and `next_permission_mode` actions to `tui.keymap.chat` so users can configure shortcuts that cycle through the available built-in permission modes. - Apply shortcut selections only to the active session, without writing them to `config.toml`, and report update failures in the transcript. - Exclude modes disallowed by managed requirements or platform safety checks, reject printable-key bindings, and ignore these bindings in project config. ## Testing - Cover mode cycling, unavailable modes, session-only updates, server errors, stale threads, keymap validation, and project-config sanitization. GitOrigin-RevId: c3cfdf1e732b7b7bfe8f53fcac19a37e593bbfab
3643 lines
142 KiB
Rust
3643 lines
142 KiB
Rust
//! Runtime keymap resolution for the TUI.
|
|
//!
|
|
//! This module converts deserialized config (`TuiKeymap`) into a concrete
|
|
//! `RuntimeKeymap` used by input handlers at runtime.
|
|
//!
|
|
//! Key responsibilities:
|
|
//!
|
|
//! 1. Apply deterministic precedence (`context -> global fallback -> defaults`).
|
|
//! 2. Parse canonical key spec strings into `KeyBinding` values.
|
|
//! 3. Enforce uniqueness across runtime surfaces so one key cannot trigger
|
|
//! multiple actions on the same focused input path.
|
|
//! 4. Return actionable, user-facing error messages with config paths and next
|
|
//! steps.
|
|
//!
|
|
//! Non-responsibilities:
|
|
//!
|
|
//! 1. This module does not decide which action should run in a given screen.
|
|
//! Callers resolve actions by checking the relevant action binding set.
|
|
//! 2. This module does not persist configuration; it only resolves loaded config.
|
|
|
|
use crate::key_hint;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::key_hint::KeyBindingListExt;
|
|
use crate::key_hint::ShortcutHint;
|
|
use codex_config::types::KeybindingsSpec;
|
|
use codex_config::types::MAX_FUNCTION_KEY;
|
|
use codex_config::types::TuiKeymap;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyModifiers;
|
|
use serde::Serialize;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
mod bindings;
|
|
mod chords;
|
|
|
|
pub(crate) use bindings::KeymapContext;
|
|
pub(crate) use bindings::bindings_for_action;
|
|
pub(crate) use bindings::keymap_action_id;
|
|
use bindings::runtime_action_bindings;
|
|
pub(crate) use chords::KEY_CHORD_TIMEOUT;
|
|
pub(crate) use chords::KeyChordMatch;
|
|
pub(crate) use chords::KeyChordMatcher;
|
|
pub(crate) use chords::KeymapContextSet;
|
|
pub(crate) use chords::RuntimeChordKeymap;
|
|
|
|
/// Runtime keymap used by TUI input handlers.
|
|
///
|
|
/// Resolution precedence is:
|
|
///
|
|
/// 1. Context-specific binding (`tui.keymap.<context>`).
|
|
/// 2. `tui.keymap.global` for actions that support global fallback.
|
|
/// 3. Built-in defaults.
|
|
///
|
|
/// This is the only shape UI code should use for dispatch. It represents a
|
|
/// fully resolved snapshot with parsing, fallback, explicit unbinding, and
|
|
/// duplicate-key validation already applied. If a caller keeps using an older
|
|
/// snapshot after config changes, visible hints and active handlers can drift.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct RuntimeKeymap {
|
|
pub(crate) app: AppKeymap,
|
|
pub(crate) chords: Arc<RuntimeChordKeymap>,
|
|
pub(crate) chat: ChatKeymap,
|
|
pub(crate) composer: ComposerKeymap,
|
|
pub(crate) editor: Arc<EditorKeymap>,
|
|
pub(crate) vim_normal: VimNormalKeymap,
|
|
pub(crate) vim_operator: VimOperatorKeymap,
|
|
pub(crate) vim_text_object: VimTextObjectKeymap,
|
|
pub(crate) pager: PagerKeymap,
|
|
pub(crate) list: ListKeymap,
|
|
pub(crate) agents: AgentsKeymap,
|
|
pub(crate) approval: ApprovalKeymap,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct AppKeymap {
|
|
/// Open the daemon-wide agent-session overview.
|
|
pub(crate) open_agents: Vec<KeyBinding>,
|
|
/// Open transcript overlay.
|
|
pub(crate) open_transcript: Vec<KeyBinding>,
|
|
/// Open external editor for the current draft.
|
|
pub(crate) open_external_editor: Vec<KeyBinding>,
|
|
/// Copy the last agent response to the clipboard.
|
|
pub(crate) copy: Vec<KeyBinding>,
|
|
/// Clear the terminal UI.
|
|
pub(crate) clear_terminal: Vec<KeyBinding>,
|
|
/// Toggle Vim mode for the composer input.
|
|
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
|
|
/// Toggle Fast mode.
|
|
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
|
|
/// Toggle raw scrollback mode for copy-friendly transcript selection.
|
|
pub(crate) toggle_raw_output: Vec<KeyBinding>,
|
|
/// Switch between a side conversation and its parent without closing either.
|
|
pub(crate) toggle_side_conversation: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Chat-level keybindings evaluated at the app event layer.
|
|
///
|
|
/// These participate in the first app-scope conflict validation pass alongside
|
|
/// `AppKeymap` actions because both are checked before input reaches the
|
|
/// composer. Dispatch gating (empty-composer guard for backtrack) happens in
|
|
/// handler code, not here.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ChatKeymap {
|
|
/// Interrupt the active turn.
|
|
pub(crate) interrupt_turn: Vec<KeyBinding>,
|
|
/// Decrease the active reasoning effort.
|
|
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
|
/// Increase the active reasoning effort.
|
|
pub(crate) increase_reasoning_effort: Vec<KeyBinding>,
|
|
/// Switch to the previous available permission mode.
|
|
pub(crate) previous_permission_mode: Vec<KeyBinding>,
|
|
/// Switch to the next available permission mode.
|
|
pub(crate) next_permission_mode: Vec<KeyBinding>,
|
|
/// Edit the most recently queued message.
|
|
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Composer-level keybindings validated in the second app-scope conflict pass.
|
|
///
|
|
/// App-level handlers execute before the composer receives input, so any key
|
|
/// bound here that also appears in `AppKeymap` would be silently intercepted.
|
|
/// The conflict validator prevents this by checking app + composer uniqueness.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ComposerKeymap {
|
|
/// Submit current draft.
|
|
pub(crate) submit: Vec<KeyBinding>,
|
|
/// Queue current draft while a task is running.
|
|
pub(crate) queue: Vec<KeyBinding>,
|
|
/// Toggle composer shortcut overlay.
|
|
pub(crate) toggle_shortcuts: Vec<KeyBinding>,
|
|
/// Open reverse history search or move to the previous match.
|
|
pub(crate) history_search_previous: Vec<KeyBinding>,
|
|
/// Move to the next match in reverse history search.
|
|
pub(crate) history_search_next: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Editor-specific keybindings used by the composer textarea.
|
|
///
|
|
/// These bindings are interpreted only by text-editing widgets and do not
|
|
/// participate in global/chat fallback resolution.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct EditorKeymap {
|
|
pub(crate) insert_newline: Vec<KeyBinding>,
|
|
pub(crate) move_left: Vec<KeyBinding>,
|
|
pub(crate) move_right: Vec<KeyBinding>,
|
|
pub(crate) move_up: Vec<KeyBinding>,
|
|
pub(crate) move_down: Vec<KeyBinding>,
|
|
pub(crate) move_word_left: Vec<KeyBinding>,
|
|
pub(crate) move_word_right: Vec<KeyBinding>,
|
|
pub(crate) move_line_start: Vec<KeyBinding>,
|
|
pub(crate) move_line_end: Vec<KeyBinding>,
|
|
pub(crate) delete_backward: Vec<KeyBinding>,
|
|
pub(crate) delete_forward: Vec<KeyBinding>,
|
|
pub(crate) delete_backward_word: Vec<KeyBinding>,
|
|
pub(crate) delete_forward_word: Vec<KeyBinding>,
|
|
pub(crate) kill_line_start: Vec<KeyBinding>,
|
|
pub(crate) kill_whole_line: Vec<KeyBinding>,
|
|
pub(crate) kill_line_end: Vec<KeyBinding>,
|
|
pub(crate) yank: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Vim normal-mode keybindings for modal editing in the composer textarea.
|
|
///
|
|
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
|
|
/// or editing key here either moves the cursor, triggers an operator-pending
|
|
/// state (via `start_delete_operator`, `start_yank_operator`, or `start_change_operator`), or transitions
|
|
/// to insert mode. Default bindings include both `shift(letter)` and
|
|
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
|
|
/// handle cross-terminal shift-reporting inconsistencies.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct VimNormalKeymap {
|
|
pub(crate) enter_insert: Vec<KeyBinding>,
|
|
pub(crate) append_after_cursor: Vec<KeyBinding>,
|
|
pub(crate) append_line_end: Vec<KeyBinding>,
|
|
pub(crate) insert_line_start: Vec<KeyBinding>,
|
|
pub(crate) open_line_below: Vec<KeyBinding>,
|
|
pub(crate) open_line_above: Vec<KeyBinding>,
|
|
pub(crate) move_left: Vec<KeyBinding>,
|
|
pub(crate) move_right: Vec<KeyBinding>,
|
|
pub(crate) move_up: Vec<KeyBinding>,
|
|
pub(crate) move_down: Vec<KeyBinding>,
|
|
pub(crate) move_word_forward: Vec<KeyBinding>,
|
|
pub(crate) move_word_backward: Vec<KeyBinding>,
|
|
pub(crate) move_word_end: Vec<KeyBinding>,
|
|
pub(crate) move_line_start: Vec<KeyBinding>,
|
|
pub(crate) move_line_end: Vec<KeyBinding>,
|
|
pub(crate) delete_char: Vec<KeyBinding>,
|
|
pub(crate) replace_char: Vec<KeyBinding>,
|
|
pub(crate) substitute_char: Vec<KeyBinding>,
|
|
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
|
pub(crate) change_to_line_end: Vec<KeyBinding>,
|
|
pub(crate) yank_line: Vec<KeyBinding>,
|
|
pub(crate) paste_after: Vec<KeyBinding>,
|
|
pub(crate) start_delete_operator: Vec<KeyBinding>,
|
|
pub(crate) start_yank_operator: Vec<KeyBinding>,
|
|
pub(crate) start_change_operator: Vec<KeyBinding>,
|
|
pub(crate) cancel_operator: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode.
|
|
///
|
|
/// When a delete, yank, or change operator is
|
|
/// pressed, the next keypress is matched against this context to determine the
|
|
/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole
|
|
/// line. `Esc` cancels the pending operator and returns to normal mode.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct VimOperatorKeymap {
|
|
pub(crate) delete_line: Vec<KeyBinding>,
|
|
pub(crate) yank_line: Vec<KeyBinding>,
|
|
pub(crate) motion_left: Vec<KeyBinding>,
|
|
pub(crate) motion_right: Vec<KeyBinding>,
|
|
pub(crate) motion_up: Vec<KeyBinding>,
|
|
pub(crate) motion_down: Vec<KeyBinding>,
|
|
pub(crate) motion_word_forward: Vec<KeyBinding>,
|
|
pub(crate) motion_word_backward: Vec<KeyBinding>,
|
|
pub(crate) motion_word_end: Vec<KeyBinding>,
|
|
pub(crate) motion_line_start: Vec<KeyBinding>,
|
|
pub(crate) motion_line_end: Vec<KeyBinding>,
|
|
pub(crate) select_inner_text_object: Vec<KeyBinding>,
|
|
pub(crate) select_around_text_object: Vec<KeyBinding>,
|
|
pub(crate) cancel: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Vim text-object keybindings active after an operator plus inner/around prefix.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct VimTextObjectKeymap {
|
|
pub(crate) word: Vec<KeyBinding>,
|
|
pub(crate) big_word: Vec<KeyBinding>,
|
|
pub(crate) parentheses: Vec<KeyBinding>,
|
|
pub(crate) brackets: Vec<KeyBinding>,
|
|
pub(crate) braces: Vec<KeyBinding>,
|
|
pub(crate) double_quote: Vec<KeyBinding>,
|
|
pub(crate) single_quote: Vec<KeyBinding>,
|
|
pub(crate) backtick: Vec<KeyBinding>,
|
|
pub(crate) cancel: Vec<KeyBinding>,
|
|
}
|
|
|
|
/// Pager/overlay keybindings for transcript and static help views.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct PagerKeymap {
|
|
pub(crate) scroll_up: Vec<KeyBinding>,
|
|
pub(crate) scroll_down: Vec<KeyBinding>,
|
|
pub(crate) page_up: Vec<KeyBinding>,
|
|
pub(crate) page_down: Vec<KeyBinding>,
|
|
pub(crate) half_page_up: Vec<KeyBinding>,
|
|
pub(crate) half_page_down: Vec<KeyBinding>,
|
|
pub(crate) jump_top: Vec<KeyBinding>,
|
|
pub(crate) jump_bottom: Vec<KeyBinding>,
|
|
pub(crate) close: Vec<KeyBinding>,
|
|
pub(crate) close_transcript: Vec<KeyBinding>,
|
|
chord_hints: Arc<RuntimeChordKeymap>,
|
|
}
|
|
|
|
impl PagerKeymap {
|
|
pub(crate) fn primary_hint(
|
|
&self,
|
|
action: &'static str,
|
|
bindings: &[KeyBinding],
|
|
) -> Option<ShortcutHint> {
|
|
let action = keymap_action_id(KeymapContext::Pager.config_name(), action)?;
|
|
self.chord_hints.primary_hint(action, bindings)
|
|
}
|
|
}
|
|
|
|
/// Semantic navigation and confirmation actions shared by list-like views.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ListAction {
|
|
MoveUp,
|
|
MoveDown,
|
|
MoveLeft,
|
|
MoveRight,
|
|
PageUp,
|
|
PageDown,
|
|
JumpTop,
|
|
JumpBottom,
|
|
Accept,
|
|
Cancel,
|
|
}
|
|
|
|
impl ListAction {
|
|
fn config_name(self) -> &'static str {
|
|
match self {
|
|
Self::MoveUp => "move_up",
|
|
Self::MoveDown => "move_down",
|
|
Self::MoveLeft => "move_left",
|
|
Self::MoveRight => "move_right",
|
|
Self::PageUp => "page_up",
|
|
Self::PageDown => "page_down",
|
|
Self::JumpTop => "jump_top",
|
|
Self::JumpBottom => "jump_bottom",
|
|
Self::Accept => "accept",
|
|
Self::Cancel => "cancel",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generic list picker keybindings shared across popup list views.
|
|
///
|
|
/// These actions describe list intent rather than a specific widget layout.
|
|
/// Vertical actions move the highlighted row, page and jump actions move within
|
|
/// the current filtered row set, and horizontal actions are available to views
|
|
/// that expose adjacent choices such as tabs, toolbar values, or ordered item
|
|
/// movement. Views that also accept search text are responsible for checking
|
|
/// `is_plain_text_key_event` before dispatching plain-character bindings so a
|
|
/// configured `j`, `k`, `h`, or `l` does not steal query input.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ListKeymap {
|
|
pub(crate) move_up: Vec<KeyBinding>,
|
|
pub(crate) move_down: Vec<KeyBinding>,
|
|
pub(crate) move_left: Vec<KeyBinding>,
|
|
pub(crate) move_right: Vec<KeyBinding>,
|
|
pub(crate) page_up: Vec<KeyBinding>,
|
|
pub(crate) page_down: Vec<KeyBinding>,
|
|
pub(crate) jump_top: Vec<KeyBinding>,
|
|
pub(crate) jump_bottom: Vec<KeyBinding>,
|
|
pub(crate) accept: Vec<KeyBinding>,
|
|
pub(crate) cancel: Vec<KeyBinding>,
|
|
chord_hints: Arc<RuntimeChordKeymap>,
|
|
}
|
|
|
|
impl ListKeymap {
|
|
pub(crate) fn bindings_for(&self, action: ListAction) -> &[KeyBinding] {
|
|
match action {
|
|
ListAction::MoveUp => &self.move_up,
|
|
ListAction::MoveDown => &self.move_down,
|
|
ListAction::MoveLeft => &self.move_left,
|
|
ListAction::MoveRight => &self.move_right,
|
|
ListAction::PageUp => &self.page_up,
|
|
ListAction::PageDown => &self.page_down,
|
|
ListAction::JumpTop => &self.jump_top,
|
|
ListAction::JumpBottom => &self.jump_bottom,
|
|
ListAction::Accept => &self.accept,
|
|
ListAction::Cancel => &self.cancel,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn action_for(&self, event: KeyEvent) -> Option<ListAction> {
|
|
[
|
|
ListAction::MoveUp,
|
|
ListAction::MoveDown,
|
|
ListAction::MoveLeft,
|
|
ListAction::MoveRight,
|
|
ListAction::PageUp,
|
|
ListAction::PageDown,
|
|
ListAction::JumpTop,
|
|
ListAction::JumpBottom,
|
|
ListAction::Accept,
|
|
ListAction::Cancel,
|
|
]
|
|
.into_iter()
|
|
.find(|action| self.bindings_for(*action).is_pressed(event))
|
|
}
|
|
|
|
pub(crate) fn primary_hint(&self, action: ListAction) -> Option<ShortcutHint> {
|
|
let action_id = keymap_action_id(KeymapContext::List.config_name(), action.config_name())?;
|
|
self.chord_hints
|
|
.primary_hint(action_id, self.bindings_for(action))
|
|
}
|
|
}
|
|
|
|
/// Task-management shortcuts specific to the shared agents dashboard.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct AgentsKeymap {
|
|
pub(crate) search: Vec<KeyBinding>,
|
|
pub(crate) new_task: Vec<KeyBinding>,
|
|
pub(crate) rename: Vec<KeyBinding>,
|
|
pub(crate) stop: Vec<KeyBinding>,
|
|
pub(crate) toggle_grouping: Vec<KeyBinding>,
|
|
chord_hints: Arc<RuntimeChordKeymap>,
|
|
}
|
|
|
|
impl AgentsKeymap {
|
|
pub(crate) fn primary_hint(
|
|
&self,
|
|
action: &'static str,
|
|
bindings: &[KeyBinding],
|
|
) -> Option<ShortcutHint> {
|
|
let action_id = keymap_action_id(KeymapContext::Agents.config_name(), action)?;
|
|
self.chord_hints.primary_hint(action_id, bindings)
|
|
}
|
|
}
|
|
|
|
/// Approval modal keybindings.
|
|
///
|
|
/// This covers both selection actions and the "open details fullscreen" escape
|
|
/// hatch for large approval payloads.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ApprovalKeymap {
|
|
pub(crate) open_fullscreen: Vec<KeyBinding>,
|
|
pub(crate) open_thread: Vec<KeyBinding>,
|
|
pub(crate) approve: Vec<KeyBinding>,
|
|
pub(crate) approve_for_session: Vec<KeyBinding>,
|
|
pub(crate) approve_for_prefix: Vec<KeyBinding>,
|
|
pub(crate) deny: Vec<KeyBinding>,
|
|
pub(crate) decline: Vec<KeyBinding>,
|
|
pub(crate) cancel: Vec<KeyBinding>,
|
|
chord_hints: Arc<RuntimeChordKeymap>,
|
|
}
|
|
|
|
impl ApprovalKeymap {
|
|
pub(crate) fn primary_hint(
|
|
&self,
|
|
action: &'static str,
|
|
bindings: &[KeyBinding],
|
|
) -> Option<ShortcutHint> {
|
|
let action = keymap_action_id(KeymapContext::Approval.config_name(), action)?;
|
|
self.chord_hints.primary_hint(action, bindings)
|
|
}
|
|
|
|
pub(crate) fn hint_for_bindings(&self, bindings: &[KeyBinding]) -> Option<ShortcutHint> {
|
|
[
|
|
("open_fullscreen", self.open_fullscreen.as_slice()),
|
|
("open_thread", self.open_thread.as_slice()),
|
|
("approve", self.approve.as_slice()),
|
|
("approve_for_session", self.approve_for_session.as_slice()),
|
|
("approve_for_prefix", self.approve_for_prefix.as_slice()),
|
|
("deny", self.deny.as_slice()),
|
|
("decline", self.decline.as_slice()),
|
|
("cancel", self.cancel.as_slice()),
|
|
]
|
|
.into_iter()
|
|
.find_map(|(action, configured)| {
|
|
(!bindings.is_empty() && bindings.iter().all(|binding| configured.contains(binding)))
|
|
.then(|| self.primary_hint(action, configured))
|
|
.flatten()
|
|
})
|
|
.or_else(|| primary_binding(bindings).map(ShortcutHint::from))
|
|
}
|
|
}
|
|
|
|
/// Returns the first binding, used as the primary UI hint for an action.
|
|
///
|
|
/// Rendering code should prefer this for concise hints while preserving all
|
|
/// bindings for actual input matching.
|
|
pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option<KeyBinding> {
|
|
user_bindings(bindings).first().copied()
|
|
}
|
|
|
|
/// Remove the trailing internal dispatch token, when present.
|
|
pub(crate) fn user_bindings(bindings: &[KeyBinding]) -> &[KeyBinding] {
|
|
if bindings
|
|
.last()
|
|
.is_some_and(|binding| chords::is_dispatch_token(*binding))
|
|
{
|
|
&bindings[..bindings.len() - 1]
|
|
} else {
|
|
bindings
|
|
}
|
|
}
|
|
|
|
/// Resolve one context-local action binding from config.
|
|
///
|
|
/// Expands to `resolve_bindings(...)` with:
|
|
/// - configured source: `tui.keymap.<context>.<action>`
|
|
/// - fallback source: the same action from built-in defaults
|
|
/// - error path: a stable string path for user-facing diagnostics
|
|
///
|
|
/// This keeps the resolution table concise while guaranteeing path strings
|
|
/// stay in sync with field names.
|
|
macro_rules! resolve_local {
|
|
($keymap:expr, $defaults:expr, $context:ident, $action:ident) => {
|
|
resolve_bindings(
|
|
($keymap).$context.$action.as_ref(),
|
|
&($defaults).$context.$action,
|
|
concat!(
|
|
"tui.keymap.",
|
|
stringify!($context),
|
|
".",
|
|
stringify!($action)
|
|
),
|
|
)?
|
|
};
|
|
}
|
|
|
|
/// Resolve one action binding with global fallback.
|
|
///
|
|
/// Expands to `resolve_bindings_with_global_fallback(...)` with precedence:
|
|
/// 1. `tui.keymap.<context>.<action>`
|
|
/// 2. `tui.keymap.global.<action>`
|
|
/// 3. built-in defaults for `<context>.<action>`
|
|
///
|
|
/// Used only for actions that intentionally support global reuse.
|
|
/// Context-local empty lists still count as configured values, so they unbind
|
|
/// the action instead of falling back to `global`.
|
|
macro_rules! resolve_with_global {
|
|
($keymap:expr, $defaults:expr, $context:ident, $action:ident) => {
|
|
resolve_bindings_with_global_fallback(
|
|
($keymap).$context.$action.as_ref(),
|
|
($keymap).global.$action.as_ref(),
|
|
&($defaults).$context.$action,
|
|
concat!(
|
|
"tui.keymap.",
|
|
stringify!($context),
|
|
".",
|
|
stringify!($action)
|
|
),
|
|
)?
|
|
};
|
|
}
|
|
|
|
/// Expand one default-table binding entry into a [`KeyBinding`].
|
|
///
|
|
/// This is a small declarative layer over `key_hint::{plain, ctrl, alt, shift}`
|
|
/// used by `default_bindings!` so `built_in_defaults` stays readable.
|
|
///
|
|
/// Supported forms:
|
|
/// - `plain(<KeyCode>)`
|
|
/// - `ctrl(<KeyCode>)`
|
|
/// - `alt(<KeyCode>)`
|
|
/// - `shift(<KeyCode>)`
|
|
/// - `raw(<KeyBinding expression>)` for bindings that do not match the helpers
|
|
/// (for example combined modifiers like Ctrl+Shift).
|
|
macro_rules! default_binding {
|
|
(plain($key:expr)) => {
|
|
key_hint::plain($key)
|
|
};
|
|
(ctrl($key:expr)) => {
|
|
key_hint::ctrl($key)
|
|
};
|
|
(alt($key:expr)) => {
|
|
key_hint::alt($key)
|
|
};
|
|
(shift($key:expr)) => {
|
|
key_hint::shift($key)
|
|
};
|
|
(raw($binding:expr)) => {
|
|
$binding
|
|
};
|
|
}
|
|
|
|
/// Build a `Vec<KeyBinding>` for built-in defaults.
|
|
///
|
|
/// This macro is intentionally scoped to built-in keymaps. Runtime
|
|
/// config parsing still goes through `parse_bindings(...)` so user errors can
|
|
/// be reported with config-path-aware diagnostics.
|
|
macro_rules! default_bindings {
|
|
($($kind:ident($($arg:tt)*)),* $(,)?) => {
|
|
vec![$(default_binding!($kind($($arg)*))),*]
|
|
};
|
|
}
|
|
|
|
impl RuntimeKeymap {
|
|
/// Return built-in defaults.
|
|
///
|
|
/// This is a convenience for tests and bootstrapping UI state before user
|
|
/// config has been loaded. It should not be used as a fallback after
|
|
/// parsing `TuiKeymap`, because doing so would ignore explicit user
|
|
/// unbindings and conflict diagnostics.
|
|
pub(crate) fn defaults() -> Self {
|
|
Self::built_in_defaults()
|
|
}
|
|
|
|
/// Resolve a runtime keymap from config, applying precedence and validation.
|
|
///
|
|
/// Returns an error when:
|
|
///
|
|
/// 1. A keybinding spec cannot be parsed.
|
|
/// 2. A context has ambiguous bindings (same key assigned to multiple actions).
|
|
///
|
|
/// The error text includes the relevant config path and a concrete next step.
|
|
/// Calling code should not merge bindings across unrelated contexts before
|
|
/// dispatch, or conflict guarantees from this resolver no longer hold.
|
|
pub(crate) fn from_config(keymap: &TuiKeymap) -> Result<Self, String> {
|
|
let defaults = Self::built_in_defaults();
|
|
let chords = Arc::new(RuntimeChordKeymap::from_config(keymap)?);
|
|
let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none()
|
|
&& ["ctrl-/", "ctrl-7"].into_iter().any(|alias| {
|
|
configured_main_surface_alias_is_used(keymap, alias)
|
|
|| configured_context_alias_is_used(&keymap.list, alias)
|
|
|| configured_context_alias_is_used(&keymap.approval, alias)
|
|
});
|
|
let open_agents_default_is_shadowed = keymap.global.open_agents.is_none()
|
|
&& (configured_main_surface_alias_is_used(keymap, "alt-a")
|
|
|| configured_context_alias_is_used(&keymap.list, "alt-a")
|
|
|| configured_context_alias_is_used(&keymap.approval, "alt-a")
|
|
|| chords.bindings.iter().any(|binding| {
|
|
binding.action.context.overlaps(KeymapContext::Global)
|
|
&& binding.chord.prefix.parts() == key_hint::alt(KeyCode::Char('a')).parts()
|
|
}));
|
|
|
|
let app = AppKeymap {
|
|
open_agents: if open_agents_default_is_shadowed {
|
|
Vec::new()
|
|
} else {
|
|
resolve_bindings(
|
|
keymap.global.open_agents.as_ref(),
|
|
&defaults.app.open_agents,
|
|
"tui.keymap.global.open_agents",
|
|
)?
|
|
},
|
|
open_transcript: resolve_bindings(
|
|
keymap.global.open_transcript.as_ref(),
|
|
&defaults.app.open_transcript,
|
|
"tui.keymap.global.open_transcript",
|
|
)?,
|
|
open_external_editor: resolve_bindings(
|
|
keymap.global.open_external_editor.as_ref(),
|
|
&defaults.app.open_external_editor,
|
|
"tui.keymap.global.open_external_editor",
|
|
)?,
|
|
copy: resolve_bindings(
|
|
keymap.global.copy.as_ref(),
|
|
&defaults.app.copy,
|
|
"tui.keymap.global.copy",
|
|
)?,
|
|
clear_terminal: resolve_bindings(
|
|
keymap.global.clear_terminal.as_ref(),
|
|
&defaults.app.clear_terminal,
|
|
"tui.keymap.global.clear_terminal",
|
|
)?,
|
|
toggle_vim_mode: resolve_bindings(
|
|
keymap.global.toggle_vim_mode.as_ref(),
|
|
&defaults.app.toggle_vim_mode,
|
|
"tui.keymap.global.toggle_vim_mode",
|
|
)?,
|
|
toggle_fast_mode: resolve_bindings(
|
|
keymap.global.toggle_fast_mode.as_ref(),
|
|
&defaults.app.toggle_fast_mode,
|
|
"tui.keymap.global.toggle_fast_mode",
|
|
)?,
|
|
toggle_raw_output: resolve_bindings(
|
|
keymap.global.toggle_raw_output.as_ref(),
|
|
&defaults.app.toggle_raw_output,
|
|
"tui.keymap.global.toggle_raw_output",
|
|
)?,
|
|
toggle_side_conversation: if side_toggle_default_is_shadowed {
|
|
Vec::new()
|
|
} else {
|
|
resolve_bindings(
|
|
keymap.global.toggle_side_conversation.as_ref(),
|
|
&defaults.app.toggle_side_conversation,
|
|
"tui.keymap.global.toggle_side_conversation",
|
|
)?
|
|
},
|
|
};
|
|
|
|
let mut chat = ChatKeymap {
|
|
interrupt_turn: resolve_bindings(
|
|
keymap.chat.interrupt_turn.as_ref(),
|
|
&defaults.chat.interrupt_turn,
|
|
"tui.keymap.chat.interrupt_turn",
|
|
)?,
|
|
decrease_reasoning_effort: resolve_bindings(
|
|
keymap.chat.decrease_reasoning_effort.as_ref(),
|
|
&defaults.chat.decrease_reasoning_effort,
|
|
"tui.keymap.chat.decrease_reasoning_effort",
|
|
)?,
|
|
increase_reasoning_effort: resolve_bindings(
|
|
keymap.chat.increase_reasoning_effort.as_ref(),
|
|
&defaults.chat.increase_reasoning_effort,
|
|
"tui.keymap.chat.increase_reasoning_effort",
|
|
)?,
|
|
previous_permission_mode: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
chat,
|
|
previous_permission_mode
|
|
),
|
|
next_permission_mode: resolve_local!(keymap, defaults, chat, next_permission_mode),
|
|
edit_queued_message: resolve_bindings(
|
|
keymap.chat.edit_queued_message.as_ref(),
|
|
&defaults.chat.edit_queued_message,
|
|
"tui.keymap.chat.edit_queued_message",
|
|
)?,
|
|
};
|
|
|
|
let composer = ComposerKeymap {
|
|
submit: resolve_with_global!(keymap, defaults, composer, submit),
|
|
queue: resolve_with_global!(keymap, defaults, composer, queue),
|
|
toggle_shortcuts: resolve_with_global!(keymap, defaults, composer, toggle_shortcuts),
|
|
history_search_previous: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
composer,
|
|
history_search_previous
|
|
),
|
|
history_search_next: resolve_local!(keymap, defaults, composer, history_search_next),
|
|
};
|
|
|
|
let editor = Arc::new(EditorKeymap {
|
|
insert_newline: resolve_local!(keymap, defaults, editor, insert_newline),
|
|
move_left: resolve_local!(keymap, defaults, editor, move_left),
|
|
move_right: resolve_local!(keymap, defaults, editor, move_right),
|
|
move_up: resolve_local!(keymap, defaults, editor, move_up),
|
|
move_down: resolve_local!(keymap, defaults, editor, move_down),
|
|
move_word_left: resolve_local!(keymap, defaults, editor, move_word_left),
|
|
move_word_right: resolve_local!(keymap, defaults, editor, move_word_right),
|
|
move_line_start: resolve_local!(keymap, defaults, editor, move_line_start),
|
|
move_line_end: resolve_local!(keymap, defaults, editor, move_line_end),
|
|
delete_backward: resolve_local!(keymap, defaults, editor, delete_backward),
|
|
delete_forward: resolve_local!(keymap, defaults, editor, delete_forward),
|
|
delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word),
|
|
delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word),
|
|
kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start),
|
|
kill_whole_line: resolve_local!(keymap, defaults, editor, kill_whole_line),
|
|
kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end),
|
|
yank: resolve_local!(keymap, defaults, editor, yank),
|
|
});
|
|
|
|
let mut vim_normal = VimNormalKeymap {
|
|
enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert),
|
|
append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor),
|
|
append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end),
|
|
insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start),
|
|
open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below),
|
|
open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above),
|
|
move_left: resolve_local!(keymap, defaults, vim_normal, move_left),
|
|
move_right: resolve_local!(keymap, defaults, vim_normal, move_right),
|
|
move_up: resolve_local!(keymap, defaults, vim_normal, move_up),
|
|
move_down: resolve_local!(keymap, defaults, vim_normal, move_down),
|
|
move_word_forward: resolve_local!(keymap, defaults, vim_normal, move_word_forward),
|
|
move_word_backward: resolve_local!(keymap, defaults, vim_normal, move_word_backward),
|
|
move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end),
|
|
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
|
|
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
|
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
|
|
replace_char: resolve_local!(keymap, defaults, vim_normal, replace_char),
|
|
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
|
|
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
|
|
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
|
|
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
|
|
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
|
|
start_delete_operator: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_normal,
|
|
start_delete_operator
|
|
),
|
|
start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator),
|
|
start_change_operator: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_normal,
|
|
start_change_operator
|
|
),
|
|
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
|
|
};
|
|
|
|
let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([
|
|
(
|
|
keymap.vim_normal.enter_insert.as_ref(),
|
|
vim_normal.enter_insert.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.append_after_cursor.as_ref(),
|
|
vim_normal.append_after_cursor.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.append_line_end.as_ref(),
|
|
vim_normal.append_line_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.insert_line_start.as_ref(),
|
|
vim_normal.insert_line_start.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.open_line_below.as_ref(),
|
|
vim_normal.open_line_below.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.open_line_above.as_ref(),
|
|
vim_normal.open_line_above.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_left.as_ref(),
|
|
vim_normal.move_left.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_right.as_ref(),
|
|
vim_normal.move_right.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_up.as_ref(),
|
|
vim_normal.move_up.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_down.as_ref(),
|
|
vim_normal.move_down.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_word_forward.as_ref(),
|
|
vim_normal.move_word_forward.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_word_backward.as_ref(),
|
|
vim_normal.move_word_backward.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_word_end.as_ref(),
|
|
vim_normal.move_word_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_line_start.as_ref(),
|
|
vim_normal.move_line_start.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.move_line_end.as_ref(),
|
|
vim_normal.move_line_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.delete_char.as_ref(),
|
|
vim_normal.delete_char.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.replace_char.as_ref(),
|
|
vim_normal.replace_char.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.substitute_char.as_ref(),
|
|
vim_normal.substitute_char.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.change_to_line_end.as_ref(),
|
|
vim_normal.change_to_line_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.delete_to_line_end.as_ref(),
|
|
vim_normal.delete_to_line_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.yank_line.as_ref(),
|
|
vim_normal.yank_line.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.paste_after.as_ref(),
|
|
vim_normal.paste_after.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.start_delete_operator.as_ref(),
|
|
vim_normal.start_delete_operator.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.start_yank_operator.as_ref(),
|
|
vim_normal.start_yank_operator.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.start_change_operator.as_ref(),
|
|
vim_normal.start_change_operator.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_normal.cancel_operator.as_ref(),
|
|
vim_normal.cancel_operator.as_slice(),
|
|
),
|
|
]);
|
|
|
|
if keymap.vim_normal.start_change_operator.is_none() {
|
|
vim_normal
|
|
.start_change_operator
|
|
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
|
}
|
|
if keymap.vim_normal.substitute_char.is_none() {
|
|
vim_normal
|
|
.substitute_char
|
|
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
|
}
|
|
if keymap.vim_normal.replace_char.is_none() {
|
|
vim_normal.replace_char.retain(|binding| {
|
|
!configured_vim_normal_bindings_to_preserve.contains(binding)
|
|
&& !chords.bindings.iter().any(|chord| {
|
|
chord.action.context == KeymapContext::VimNormal
|
|
&& chord.chord.prefix.parts() == binding.parts()
|
|
})
|
|
});
|
|
}
|
|
|
|
let mut vim_operator = VimOperatorKeymap {
|
|
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
|
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
|
|
motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left),
|
|
motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right),
|
|
motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up),
|
|
motion_down: resolve_local!(keymap, defaults, vim_operator, motion_down),
|
|
motion_word_forward: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_operator,
|
|
motion_word_forward
|
|
),
|
|
motion_word_backward: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_operator,
|
|
motion_word_backward
|
|
),
|
|
motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end),
|
|
motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start),
|
|
motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end),
|
|
select_inner_text_object: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_operator,
|
|
select_inner_text_object
|
|
),
|
|
select_around_text_object: resolve_local!(
|
|
keymap,
|
|
defaults,
|
|
vim_operator,
|
|
select_around_text_object
|
|
),
|
|
cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
|
|
};
|
|
|
|
let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([
|
|
(
|
|
keymap.vim_operator.delete_line.as_ref(),
|
|
vim_operator.delete_line.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.yank_line.as_ref(),
|
|
vim_operator.yank_line.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_left.as_ref(),
|
|
vim_operator.motion_left.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_right.as_ref(),
|
|
vim_operator.motion_right.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_up.as_ref(),
|
|
vim_operator.motion_up.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_down.as_ref(),
|
|
vim_operator.motion_down.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_word_forward.as_ref(),
|
|
vim_operator.motion_word_forward.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_word_backward.as_ref(),
|
|
vim_operator.motion_word_backward.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_word_end.as_ref(),
|
|
vim_operator.motion_word_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_line_start.as_ref(),
|
|
vim_operator.motion_line_start.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.motion_line_end.as_ref(),
|
|
vim_operator.motion_line_end.as_slice(),
|
|
),
|
|
(
|
|
keymap.vim_operator.cancel.as_ref(),
|
|
vim_operator.cancel.as_slice(),
|
|
),
|
|
]);
|
|
|
|
if keymap.vim_operator.select_inner_text_object.is_none() {
|
|
vim_operator
|
|
.select_inner_text_object
|
|
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
|
|
}
|
|
if keymap.vim_operator.select_around_text_object.is_none() {
|
|
vim_operator
|
|
.select_around_text_object
|
|
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
|
|
}
|
|
|
|
let vim_text_object = VimTextObjectKeymap {
|
|
word: resolve_local!(keymap, defaults, vim_text_object, word),
|
|
big_word: resolve_local!(keymap, defaults, vim_text_object, big_word),
|
|
parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses),
|
|
brackets: resolve_local!(keymap, defaults, vim_text_object, brackets),
|
|
braces: resolve_local!(keymap, defaults, vim_text_object, braces),
|
|
double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote),
|
|
single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote),
|
|
backtick: resolve_local!(keymap, defaults, vim_text_object, backtick),
|
|
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
|
|
};
|
|
|
|
// Reasoning arrow aliases are fallback defaults: existing explicit
|
|
// bindings on the same input path keep the keys, while explicit
|
|
// reasoning bindings remain authoritative.
|
|
if keymap.chat.decrease_reasoning_effort.is_none()
|
|
&& configured_main_surface_alias_is_used(keymap, "shift-down")
|
|
{
|
|
chat.decrease_reasoning_effort
|
|
.retain(|binding| *binding != key_hint::shift(KeyCode::Down));
|
|
}
|
|
if keymap.chat.increase_reasoning_effort.is_none()
|
|
&& configured_main_surface_alias_is_used(keymap, "shift-up")
|
|
{
|
|
chat.increase_reasoning_effort
|
|
.retain(|binding| *binding != key_hint::shift(KeyCode::Up));
|
|
}
|
|
|
|
let pager = PagerKeymap {
|
|
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
|
|
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
|
|
page_up: resolve_local!(keymap, defaults, pager, page_up),
|
|
page_down: resolve_local!(keymap, defaults, pager, page_down),
|
|
half_page_up: resolve_local!(keymap, defaults, pager, half_page_up),
|
|
half_page_down: resolve_local!(keymap, defaults, pager, half_page_down),
|
|
jump_top: resolve_local!(keymap, defaults, pager, jump_top),
|
|
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
|
|
close: resolve_local!(keymap, defaults, pager, close),
|
|
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
|
|
chord_hints: Arc::clone(&chords),
|
|
};
|
|
|
|
let mut agents = AgentsKeymap {
|
|
search: resolve_local!(keymap, defaults, agents, search),
|
|
new_task: resolve_local!(keymap, defaults, agents, new_task),
|
|
rename: resolve_local!(keymap, defaults, agents, rename),
|
|
stop: resolve_local!(keymap, defaults, agents, stop),
|
|
toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping),
|
|
chord_hints: Arc::clone(&chords),
|
|
};
|
|
|
|
let approval = ApprovalKeymap {
|
|
open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen),
|
|
open_thread: resolve_local!(keymap, defaults, approval, open_thread),
|
|
approve: resolve_local!(keymap, defaults, approval, approve),
|
|
approve_for_session: resolve_local!(keymap, defaults, approval, approve_for_session),
|
|
approve_for_prefix: resolve_local!(keymap, defaults, approval, approve_for_prefix),
|
|
deny: resolve_local!(keymap, defaults, approval, deny),
|
|
decline: resolve_local!(keymap, defaults, approval, decline),
|
|
cancel: resolve_local!(keymap, defaults, approval, cancel),
|
|
chord_hints: Arc::clone(&chords),
|
|
};
|
|
|
|
let list_move_up = resolve_local!(keymap, defaults, list, move_up);
|
|
let list_move_down = resolve_local!(keymap, defaults, list, move_down);
|
|
let list_accept = resolve_local!(keymap, defaults, list, accept);
|
|
let list_cancel = resolve_local!(keymap, defaults, list, cancel);
|
|
let configured_bindings_to_preserve = configured_bindings_to_preserve([
|
|
(
|
|
keymap.global.open_agents.as_ref(),
|
|
app.open_agents.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.open_transcript.as_ref(),
|
|
app.open_transcript.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.open_external_editor.as_ref(),
|
|
app.open_external_editor.as_slice(),
|
|
),
|
|
(keymap.global.copy.as_ref(), app.copy.as_slice()),
|
|
(
|
|
keymap.global.clear_terminal.as_ref(),
|
|
app.clear_terminal.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.toggle_vim_mode.as_ref(),
|
|
app.toggle_vim_mode.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.toggle_fast_mode.as_ref(),
|
|
app.toggle_fast_mode.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.toggle_raw_output.as_ref(),
|
|
app.toggle_raw_output.as_slice(),
|
|
),
|
|
(
|
|
keymap.global.toggle_side_conversation.as_ref(),
|
|
app.toggle_side_conversation.as_slice(),
|
|
),
|
|
(keymap.list.move_up.as_ref(), list_move_up.as_slice()),
|
|
(keymap.list.move_down.as_ref(), list_move_down.as_slice()),
|
|
(keymap.list.accept.as_ref(), list_accept.as_slice()),
|
|
(keymap.list.cancel.as_ref(), list_cancel.as_slice()),
|
|
(
|
|
keymap.approval.open_fullscreen.as_ref(),
|
|
approval.open_fullscreen.as_slice(),
|
|
),
|
|
(
|
|
keymap.approval.open_thread.as_ref(),
|
|
approval.open_thread.as_slice(),
|
|
),
|
|
(
|
|
keymap.approval.approve.as_ref(),
|
|
approval.approve.as_slice(),
|
|
),
|
|
(
|
|
keymap.approval.approve_for_session.as_ref(),
|
|
approval.approve_for_session.as_slice(),
|
|
),
|
|
(
|
|
keymap.approval.approve_for_prefix.as_ref(),
|
|
approval.approve_for_prefix.as_slice(),
|
|
),
|
|
(keymap.approval.deny.as_ref(), approval.deny.as_slice()),
|
|
(
|
|
keymap.approval.decline.as_ref(),
|
|
approval.decline.as_slice(),
|
|
),
|
|
(keymap.approval.cancel.as_ref(), approval.cancel.as_slice()),
|
|
]);
|
|
|
|
let list = ListKeymap {
|
|
move_up: list_move_up,
|
|
move_down: list_move_down,
|
|
move_left: resolve_new_default_bindings(
|
|
keymap.list.move_left.as_ref(),
|
|
&defaults.list.move_left,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.move_left",
|
|
)?,
|
|
move_right: resolve_new_default_bindings(
|
|
keymap.list.move_right.as_ref(),
|
|
&defaults.list.move_right,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.move_right",
|
|
)?,
|
|
page_up: resolve_new_default_bindings(
|
|
keymap.list.page_up.as_ref(),
|
|
&defaults.list.page_up,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.page_up",
|
|
)?,
|
|
page_down: resolve_new_default_bindings(
|
|
keymap.list.page_down.as_ref(),
|
|
&defaults.list.page_down,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.page_down",
|
|
)?,
|
|
jump_top: resolve_new_default_bindings(
|
|
keymap.list.jump_top.as_ref(),
|
|
&defaults.list.jump_top,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.jump_top",
|
|
)?,
|
|
jump_bottom: resolve_new_default_bindings(
|
|
keymap.list.jump_bottom.as_ref(),
|
|
&defaults.list.jump_bottom,
|
|
&configured_bindings_to_preserve,
|
|
"tui.keymap.list.jump_bottom",
|
|
)?,
|
|
accept: list_accept,
|
|
cancel: list_cancel,
|
|
chord_hints: Arc::clone(&chords),
|
|
};
|
|
|
|
for (configured, bindings) in [
|
|
(keymap.agents.search.as_ref(), &mut agents.search),
|
|
(keymap.agents.new_task.as_ref(), &mut agents.new_task),
|
|
(keymap.agents.rename.as_ref(), &mut agents.rename),
|
|
(keymap.agents.stop.as_ref(), &mut agents.stop),
|
|
(
|
|
keymap.agents.toggle_grouping.as_ref(),
|
|
&mut agents.toggle_grouping,
|
|
),
|
|
] {
|
|
if configured.is_none() {
|
|
bindings.retain(|binding| {
|
|
!chords.bindings.iter().any(|chord| {
|
|
chord.action.context == KeymapContext::List
|
|
&& chord.chord.prefix == *binding
|
|
})
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut resolved = Self {
|
|
app,
|
|
chords,
|
|
chat,
|
|
composer,
|
|
editor,
|
|
vim_normal,
|
|
vim_operator,
|
|
vim_text_object,
|
|
pager,
|
|
list,
|
|
agents,
|
|
approval,
|
|
};
|
|
|
|
resolved.validate_conflicts()?;
|
|
chords::validate_chord_conflicts(&resolved)?;
|
|
chords::install_dispatch_bindings(&mut resolved)?;
|
|
Ok(resolved)
|
|
}
|
|
|
|
/// Resolve the visible primary shortcut from configured declaration order.
|
|
pub(crate) fn primary_hint(
|
|
&self,
|
|
context: KeymapContext,
|
|
action: &'static str,
|
|
) -> Option<ShortcutHint> {
|
|
let action_id = keymap_action_id(context.config_name(), action)?;
|
|
let bindings = bindings_for_action(self, context.config_name(), action)?;
|
|
self.chords.primary_hint(action_id, bindings)
|
|
}
|
|
|
|
/// Built-in keymap defaults.
|
|
///
|
|
/// Some actions intentionally include compatibility variants (for example
|
|
/// both `?` and `shift-?`) because terminals disagree on whether SHIFT is
|
|
/// preserved for certain printable/control chords.
|
|
fn built_in_defaults() -> Self {
|
|
Self {
|
|
app: AppKeymap {
|
|
open_agents: default_bindings![alt(KeyCode::Char('a'))],
|
|
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
|
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
|
|
copy: default_bindings![ctrl(KeyCode::Char('o'))],
|
|
clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))],
|
|
toggle_vim_mode: default_bindings![],
|
|
toggle_fast_mode: default_bindings![],
|
|
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
|
toggle_side_conversation: default_bindings![ctrl(KeyCode::Char('/'))],
|
|
},
|
|
chords: Arc::default(),
|
|
chat: ChatKeymap {
|
|
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
|
|
decrease_reasoning_effort: default_bindings![
|
|
alt(KeyCode::Char(',')),
|
|
shift(KeyCode::Down)
|
|
],
|
|
increase_reasoning_effort: default_bindings![
|
|
alt(KeyCode::Char('.')),
|
|
shift(KeyCode::Up)
|
|
],
|
|
previous_permission_mode: default_bindings![],
|
|
next_permission_mode: default_bindings![],
|
|
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
|
},
|
|
composer: ComposerKeymap {
|
|
submit: default_bindings![plain(KeyCode::Enter)],
|
|
queue: default_bindings![plain(KeyCode::Tab)],
|
|
toggle_shortcuts: default_bindings![
|
|
plain(KeyCode::Char('?')),
|
|
shift(KeyCode::Char('?'))
|
|
],
|
|
history_search_previous: default_bindings![ctrl(KeyCode::Char('r'))],
|
|
history_search_next: default_bindings![ctrl(KeyCode::Char('s'))],
|
|
},
|
|
editor: Arc::new(EditorKeymap {
|
|
insert_newline: default_bindings![
|
|
ctrl(KeyCode::Char('j')),
|
|
ctrl(KeyCode::Char('m')),
|
|
plain(KeyCode::Enter),
|
|
shift(KeyCode::Enter),
|
|
alt(KeyCode::Enter)
|
|
],
|
|
move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('b'))],
|
|
move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('f'))],
|
|
move_up: default_bindings![plain(KeyCode::Up), ctrl(KeyCode::Char('p'))],
|
|
move_down: default_bindings![plain(KeyCode::Down), ctrl(KeyCode::Char('n'))],
|
|
move_word_left: default_bindings![
|
|
alt(KeyCode::Char('b')),
|
|
raw(KeyBinding::new(KeyCode::Left, KeyModifiers::ALT)),
|
|
raw(KeyBinding::new(KeyCode::Left, KeyModifiers::CONTROL))
|
|
],
|
|
move_word_right: default_bindings![
|
|
alt(KeyCode::Char('f')),
|
|
raw(KeyBinding::new(KeyCode::Right, KeyModifiers::ALT)),
|
|
raw(KeyBinding::new(KeyCode::Right, KeyModifiers::CONTROL))
|
|
],
|
|
move_line_start: default_bindings![plain(KeyCode::Home), ctrl(KeyCode::Char('a'))],
|
|
move_line_end: default_bindings![plain(KeyCode::End), ctrl(KeyCode::Char('e'))],
|
|
delete_backward: default_bindings![
|
|
plain(KeyCode::Backspace),
|
|
shift(KeyCode::Backspace),
|
|
ctrl(KeyCode::Char('h'))
|
|
],
|
|
delete_forward: default_bindings![
|
|
plain(KeyCode::Delete),
|
|
shift(KeyCode::Delete),
|
|
ctrl(KeyCode::Char('d'))
|
|
],
|
|
delete_backward_word: default_bindings![
|
|
alt(KeyCode::Backspace),
|
|
ctrl(KeyCode::Backspace),
|
|
raw(KeyBinding::new(
|
|
KeyCode::Backspace,
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
)),
|
|
ctrl(KeyCode::Char('w')),
|
|
raw(KeyBinding::new(
|
|
KeyCode::Char('h'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
],
|
|
delete_forward_word: default_bindings![
|
|
alt(KeyCode::Delete),
|
|
ctrl(KeyCode::Delete),
|
|
raw(KeyBinding::new(
|
|
KeyCode::Delete,
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
)),
|
|
alt(KeyCode::Char('d'))
|
|
],
|
|
kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))],
|
|
kill_whole_line: default_bindings![],
|
|
kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))],
|
|
yank: default_bindings![ctrl(KeyCode::Char('y'))],
|
|
}),
|
|
vim_normal: VimNormalKeymap {
|
|
enter_insert: default_bindings![plain(KeyCode::Char('i')), plain(KeyCode::Insert)],
|
|
append_after_cursor: default_bindings![plain(KeyCode::Char('a'))],
|
|
append_line_end: default_bindings![
|
|
shift(KeyCode::Char('a')),
|
|
plain(KeyCode::Char('A'))
|
|
],
|
|
insert_line_start: default_bindings![
|
|
shift(KeyCode::Char('i')),
|
|
plain(KeyCode::Char('I'))
|
|
],
|
|
open_line_below: default_bindings![plain(KeyCode::Char('o'))],
|
|
open_line_above: default_bindings![
|
|
shift(KeyCode::Char('o')),
|
|
plain(KeyCode::Char('O'))
|
|
],
|
|
move_left: default_bindings![plain(KeyCode::Char('h')), plain(KeyCode::Left)],
|
|
move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)],
|
|
move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)],
|
|
move_down: default_bindings![plain(KeyCode::Char('j')), plain(KeyCode::Down)],
|
|
move_word_forward: default_bindings![plain(KeyCode::Char('w'))],
|
|
move_word_backward: default_bindings![plain(KeyCode::Char('b'))],
|
|
move_word_end: default_bindings![plain(KeyCode::Char('e'))],
|
|
move_line_start: default_bindings![plain(KeyCode::Char('0'))],
|
|
move_line_end: default_bindings![
|
|
plain(KeyCode::Char('$')),
|
|
shift(KeyCode::Char('$'))
|
|
],
|
|
delete_char: default_bindings![plain(KeyCode::Char('x'))],
|
|
replace_char: default_bindings![plain(KeyCode::Char('r'))],
|
|
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
|
|
delete_to_line_end: default_bindings![
|
|
shift(KeyCode::Char('d')),
|
|
plain(KeyCode::Char('D'))
|
|
],
|
|
change_to_line_end: default_bindings![
|
|
shift(KeyCode::Char('c')),
|
|
plain(KeyCode::Char('C'))
|
|
],
|
|
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
|
|
paste_after: default_bindings![plain(KeyCode::Char('p'))],
|
|
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
|
|
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
|
|
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
|
|
cancel_operator: default_bindings![plain(KeyCode::Esc)],
|
|
},
|
|
vim_operator: VimOperatorKeymap {
|
|
delete_line: default_bindings![plain(KeyCode::Char('d'))],
|
|
yank_line: default_bindings![plain(KeyCode::Char('y'))],
|
|
motion_left: default_bindings![plain(KeyCode::Char('h'))],
|
|
motion_right: default_bindings![plain(KeyCode::Char('l'))],
|
|
motion_up: default_bindings![plain(KeyCode::Char('k'))],
|
|
motion_down: default_bindings![plain(KeyCode::Char('j'))],
|
|
motion_word_forward: default_bindings![plain(KeyCode::Char('w'))],
|
|
motion_word_backward: default_bindings![plain(KeyCode::Char('b'))],
|
|
motion_word_end: default_bindings![plain(KeyCode::Char('e'))],
|
|
motion_line_start: default_bindings![plain(KeyCode::Char('0'))],
|
|
motion_line_end: default_bindings![
|
|
plain(KeyCode::Char('$')),
|
|
shift(KeyCode::Char('$'))
|
|
],
|
|
select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))],
|
|
select_around_text_object: default_bindings![plain(KeyCode::Char('a'))],
|
|
cancel: default_bindings![plain(KeyCode::Esc)],
|
|
},
|
|
vim_text_object: VimTextObjectKeymap {
|
|
word: default_bindings![plain(KeyCode::Char('w'))],
|
|
big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))],
|
|
parentheses: default_bindings![
|
|
plain(KeyCode::Char('(')),
|
|
shift(KeyCode::Char('(')),
|
|
plain(KeyCode::Char(')')),
|
|
shift(KeyCode::Char(')')),
|
|
plain(KeyCode::Char('b'))
|
|
],
|
|
brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))],
|
|
braces: default_bindings![
|
|
plain(KeyCode::Char('{')),
|
|
shift(KeyCode::Char('{')),
|
|
plain(KeyCode::Char('}')),
|
|
shift(KeyCode::Char('}')),
|
|
shift(KeyCode::Char('b')),
|
|
plain(KeyCode::Char('B'))
|
|
],
|
|
double_quote: default_bindings![
|
|
plain(KeyCode::Char('"')),
|
|
shift(KeyCode::Char('"'))
|
|
],
|
|
single_quote: default_bindings![plain(KeyCode::Char('\''))],
|
|
backtick: default_bindings![plain(KeyCode::Char('`'))],
|
|
cancel: default_bindings![plain(KeyCode::Esc)],
|
|
},
|
|
pager: PagerKeymap {
|
|
scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))],
|
|
scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))],
|
|
page_up: default_bindings![
|
|
plain(KeyCode::PageUp),
|
|
shift(KeyCode::Char(' ')),
|
|
ctrl(KeyCode::Char('b'))
|
|
],
|
|
page_down: default_bindings![
|
|
plain(KeyCode::PageDown),
|
|
plain(KeyCode::Char(' ')),
|
|
ctrl(KeyCode::Char('f'))
|
|
],
|
|
half_page_up: default_bindings![ctrl(KeyCode::Char('u'))],
|
|
half_page_down: default_bindings![ctrl(KeyCode::Char('d'))],
|
|
jump_top: default_bindings![plain(KeyCode::Home)],
|
|
jump_bottom: default_bindings![plain(KeyCode::End)],
|
|
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
|
|
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
|
chord_hints: Arc::default(),
|
|
},
|
|
list: ListKeymap {
|
|
move_up: default_bindings![
|
|
plain(KeyCode::Up),
|
|
ctrl(KeyCode::Char('p')),
|
|
ctrl(KeyCode::Char('k')),
|
|
plain(KeyCode::Char('k'))
|
|
],
|
|
move_down: default_bindings![
|
|
plain(KeyCode::Down),
|
|
ctrl(KeyCode::Char('n')),
|
|
ctrl(KeyCode::Char('j')),
|
|
plain(KeyCode::Char('j'))
|
|
],
|
|
move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('h'))],
|
|
move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('l'))],
|
|
page_up: default_bindings![plain(KeyCode::PageUp), ctrl(KeyCode::Char('b'))],
|
|
page_down: default_bindings![plain(KeyCode::PageDown), ctrl(KeyCode::Char('f'))],
|
|
jump_top: default_bindings![plain(KeyCode::Home)],
|
|
jump_bottom: default_bindings![plain(KeyCode::End)],
|
|
accept: default_bindings![plain(KeyCode::Enter)],
|
|
cancel: default_bindings![plain(KeyCode::Esc)],
|
|
chord_hints: Arc::default(),
|
|
},
|
|
agents: AgentsKeymap {
|
|
search: default_bindings![ctrl(KeyCode::Char('f'))],
|
|
new_task: default_bindings![ctrl(KeyCode::Char('n'))],
|
|
rename: default_bindings![ctrl(KeyCode::Char('r'))],
|
|
stop: default_bindings![ctrl(KeyCode::Char('x'))],
|
|
toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))],
|
|
chord_hints: Arc::default(),
|
|
},
|
|
approval: ApprovalKeymap {
|
|
open_fullscreen: default_bindings![
|
|
ctrl(KeyCode::Char('a')),
|
|
raw(KeyBinding::new(
|
|
KeyCode::Char('a'),
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
))
|
|
],
|
|
open_thread: default_bindings![plain(KeyCode::Char('o'))],
|
|
approve: default_bindings![plain(KeyCode::Char('y'))],
|
|
approve_for_session: default_bindings![plain(KeyCode::Char('a'))],
|
|
approve_for_prefix: default_bindings![plain(KeyCode::Char('p'))],
|
|
deny: default_bindings![plain(KeyCode::Char('d'))],
|
|
decline: default_bindings![plain(KeyCode::Esc), plain(KeyCode::Char('n'))],
|
|
cancel: default_bindings![plain(KeyCode::Char('c'))],
|
|
chord_hints: Arc::default(),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Reject ambiguous bindings in scopes that are evaluated together.
|
|
///
|
|
/// We validate in multiple passes because runtime handling has mixed
|
|
/// precedence:
|
|
///
|
|
/// 1. `app` actions can shadow composer actions because app checks run
|
|
/// before forwarding to the composer.
|
|
/// 2. Contexts with hard-coded sequence behavior, such as edit-previous
|
|
/// backtracking, intentionally stay outside this configurable keymap.
|
|
fn validate_conflicts(&self) -> Result<(), String> {
|
|
for (action, bindings) in [
|
|
(
|
|
"previous_permission_mode",
|
|
&self.chat.previous_permission_mode,
|
|
),
|
|
("next_permission_mode", &self.chat.next_permission_mode),
|
|
] {
|
|
if bindings.iter().any(|binding| {
|
|
let (code, modifiers) = binding.parts();
|
|
crate::key_hint::is_plain_text_key_event(KeyEvent::new(code, modifiers))
|
|
|| matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers)
|
|
}) {
|
|
return Err(format!(
|
|
"tui.keymap.chat.{action}: printable keys are reserved for text input"
|
|
));
|
|
}
|
|
}
|
|
#[cfg(unix)]
|
|
if self
|
|
.app
|
|
.open_agents
|
|
.contains(&key_hint::ctrl(KeyCode::Char('z')))
|
|
{
|
|
return Err(
|
|
"tui.keymap.global.open_agents: ctrl-z is reserved for suspend".to_string(),
|
|
);
|
|
}
|
|
if self.app.open_agents.iter().any(|binding| {
|
|
matches!(binding.parts(), (KeyCode::Char(_), modifiers)
|
|
if crate::key_hint::is_altgr(modifiers))
|
|
}) {
|
|
return Err(
|
|
"tui.keymap.global.open_agents: AltGr characters are reserved for text input"
|
|
.to_string(),
|
|
);
|
|
}
|
|
let mut side_toggle_bindings = self.app.toggle_side_conversation.clone();
|
|
let slash_binding = key_hint::ctrl(KeyCode::Char('/'));
|
|
let legacy_slash_binding = key_hint::ctrl(KeyCode::Char('7'));
|
|
if side_toggle_bindings.contains(&slash_binding)
|
|
&& !side_toggle_bindings.contains(&legacy_slash_binding)
|
|
{
|
|
side_toggle_bindings.push(legacy_slash_binding);
|
|
}
|
|
|
|
validate_unique(
|
|
"app",
|
|
[
|
|
("open_agents", self.app.open_agents.as_slice()),
|
|
("open_transcript", self.app.open_transcript.as_slice()),
|
|
(
|
|
"open_external_editor",
|
|
self.app.open_external_editor.as_slice(),
|
|
),
|
|
("copy", self.app.copy.as_slice()),
|
|
("clear_terminal", self.app.clear_terminal.as_slice()),
|
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
|
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
|
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
|
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
|
(
|
|
"chat.decrease_reasoning_effort",
|
|
self.chat.decrease_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.increase_reasoning_effort",
|
|
self.chat.increase_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.previous_permission_mode",
|
|
self.chat.previous_permission_mode.as_slice(),
|
|
),
|
|
(
|
|
"chat.next_permission_mode",
|
|
self.chat.next_permission_mode.as_slice(),
|
|
),
|
|
(
|
|
"chat.edit_queued_message",
|
|
self.chat.edit_queued_message.as_slice(),
|
|
),
|
|
("composer.submit", self.composer.submit.as_slice()),
|
|
("composer.queue", self.composer.queue.as_slice()),
|
|
(
|
|
"composer.toggle_shortcuts",
|
|
self.composer.toggle_shortcuts.as_slice(),
|
|
),
|
|
(
|
|
"composer.history_search_previous",
|
|
self.composer.history_search_previous.as_slice(),
|
|
),
|
|
(
|
|
"composer.history_search_next",
|
|
self.composer.history_search_next.as_slice(),
|
|
),
|
|
],
|
|
)?;
|
|
|
|
validate_no_reserved(
|
|
"main",
|
|
[
|
|
("open_agents", self.app.open_agents.as_slice()),
|
|
("open_transcript", self.app.open_transcript.as_slice()),
|
|
(
|
|
"open_external_editor",
|
|
self.app.open_external_editor.as_slice(),
|
|
),
|
|
("copy", self.app.copy.as_slice()),
|
|
("clear_terminal", self.app.clear_terminal.as_slice()),
|
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
|
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
|
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
|
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
|
(
|
|
"chat.decrease_reasoning_effort",
|
|
self.chat.decrease_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.increase_reasoning_effort",
|
|
self.chat.increase_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.previous_permission_mode",
|
|
self.chat.previous_permission_mode.as_slice(),
|
|
),
|
|
(
|
|
"chat.next_permission_mode",
|
|
self.chat.next_permission_mode.as_slice(),
|
|
),
|
|
(
|
|
"chat.edit_queued_message",
|
|
self.chat.edit_queued_message.as_slice(),
|
|
),
|
|
("composer.submit", self.composer.submit.as_slice()),
|
|
("composer.queue", self.composer.queue.as_slice()),
|
|
(
|
|
"composer.toggle_shortcuts",
|
|
self.composer.toggle_shortcuts.as_slice(),
|
|
),
|
|
(
|
|
"composer.history_search_previous",
|
|
self.composer.history_search_previous.as_slice(),
|
|
),
|
|
(
|
|
"composer.history_search_next",
|
|
self.composer.history_search_next.as_slice(),
|
|
),
|
|
],
|
|
MAIN_RESERVED_BINDINGS,
|
|
[(
|
|
"chat.interrupt_turn",
|
|
"fixed.backtrack",
|
|
key_hint::plain(KeyCode::Esc),
|
|
)],
|
|
)?;
|
|
|
|
validate_no_shadow_with_allowed_overlaps(
|
|
"app",
|
|
[
|
|
("open_agents", self.app.open_agents.as_slice()),
|
|
("open_transcript", self.app.open_transcript.as_slice()),
|
|
(
|
|
"open_external_editor",
|
|
self.app.open_external_editor.as_slice(),
|
|
),
|
|
("copy", self.app.copy.as_slice()),
|
|
("clear_terminal", self.app.clear_terminal.as_slice()),
|
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
|
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
|
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
|
],
|
|
[
|
|
("list.move_up", self.list.move_up.as_slice()),
|
|
("list.move_down", self.list.move_down.as_slice()),
|
|
("list.move_left", self.list.move_left.as_slice()),
|
|
("list.move_right", self.list.move_right.as_slice()),
|
|
("list.page_up", self.list.page_up.as_slice()),
|
|
("list.page_down", self.list.page_down.as_slice()),
|
|
("list.jump_top", self.list.jump_top.as_slice()),
|
|
("list.jump_bottom", self.list.jump_bottom.as_slice()),
|
|
("list.accept", self.list.accept.as_slice()),
|
|
("list.cancel", self.list.cancel.as_slice()),
|
|
(
|
|
"approval.open_fullscreen",
|
|
self.approval.open_fullscreen.as_slice(),
|
|
),
|
|
("approval.open_thread", self.approval.open_thread.as_slice()),
|
|
("approval.approve", self.approval.approve.as_slice()),
|
|
(
|
|
"approval.approve_for_session",
|
|
self.approval.approve_for_session.as_slice(),
|
|
),
|
|
(
|
|
"approval.approve_for_prefix",
|
|
self.approval.approve_for_prefix.as_slice(),
|
|
),
|
|
("approval.deny", self.approval.deny.as_slice()),
|
|
("approval.decline", self.approval.decline.as_slice()),
|
|
("approval.cancel", self.approval.cancel.as_slice()),
|
|
],
|
|
[(
|
|
"clear_terminal",
|
|
"list.move_right",
|
|
key_hint::ctrl(KeyCode::Char('l')),
|
|
)],
|
|
)?;
|
|
|
|
// The request-user-input overlay consumes turn interruption before
|
|
// configurable question navigation reaches its list handler.
|
|
validate_no_shadow_with_allowed_overlaps(
|
|
"request_user_input",
|
|
[("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())],
|
|
[
|
|
("list.move_left", self.list.move_left.as_slice()),
|
|
("list.move_right", self.list.move_right.as_slice()),
|
|
],
|
|
[],
|
|
)?;
|
|
|
|
// While the composer is focused, these main-surface handlers always
|
|
// consume matching keys before the event reaches the textarea editor.
|
|
validate_no_shadow_with_allowed_overlaps(
|
|
"main",
|
|
[
|
|
("open_agents", self.app.open_agents.as_slice()),
|
|
("open_transcript", self.app.open_transcript.as_slice()),
|
|
(
|
|
"open_external_editor",
|
|
self.app.open_external_editor.as_slice(),
|
|
),
|
|
("copy", self.app.copy.as_slice()),
|
|
("clear_terminal", self.app.clear_terminal.as_slice()),
|
|
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
|
(
|
|
"chat.decrease_reasoning_effort",
|
|
self.chat.decrease_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.increase_reasoning_effort",
|
|
self.chat.increase_reasoning_effort.as_slice(),
|
|
),
|
|
(
|
|
"chat.previous_permission_mode",
|
|
self.chat.previous_permission_mode.as_slice(),
|
|
),
|
|
(
|
|
"chat.next_permission_mode",
|
|
self.chat.next_permission_mode.as_slice(),
|
|
),
|
|
("composer.submit", self.composer.submit.as_slice()),
|
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
|
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
|
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
|
(
|
|
"composer.history_search_previous",
|
|
self.composer.history_search_previous.as_slice(),
|
|
),
|
|
],
|
|
[
|
|
(
|
|
"editor.insert_newline",
|
|
self.editor.insert_newline.as_slice(),
|
|
),
|
|
("editor.move_left", self.editor.move_left.as_slice()),
|
|
("editor.move_right", self.editor.move_right.as_slice()),
|
|
("editor.move_up", self.editor.move_up.as_slice()),
|
|
("editor.move_down", self.editor.move_down.as_slice()),
|
|
(
|
|
"editor.move_word_left",
|
|
self.editor.move_word_left.as_slice(),
|
|
),
|
|
(
|
|
"editor.move_word_right",
|
|
self.editor.move_word_right.as_slice(),
|
|
),
|
|
(
|
|
"editor.move_line_start",
|
|
self.editor.move_line_start.as_slice(),
|
|
),
|
|
("editor.move_line_end", self.editor.move_line_end.as_slice()),
|
|
(
|
|
"editor.delete_backward",
|
|
self.editor.delete_backward.as_slice(),
|
|
),
|
|
(
|
|
"editor.delete_forward",
|
|
self.editor.delete_forward.as_slice(),
|
|
),
|
|
(
|
|
"editor.delete_backward_word",
|
|
self.editor.delete_backward_word.as_slice(),
|
|
),
|
|
(
|
|
"editor.delete_forward_word",
|
|
self.editor.delete_forward_word.as_slice(),
|
|
),
|
|
(
|
|
"editor.kill_line_start",
|
|
self.editor.kill_line_start.as_slice(),
|
|
),
|
|
(
|
|
"editor.kill_whole_line",
|
|
self.editor.kill_whole_line.as_slice(),
|
|
),
|
|
("editor.kill_line_end", self.editor.kill_line_end.as_slice()),
|
|
("editor.yank", self.editor.yank.as_slice()),
|
|
],
|
|
[(
|
|
"composer.submit",
|
|
"editor.insert_newline",
|
|
key_hint::plain(KeyCode::Enter),
|
|
)],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"editor",
|
|
[
|
|
("insert_newline", self.editor.insert_newline.as_slice()),
|
|
("move_left", self.editor.move_left.as_slice()),
|
|
("move_right", self.editor.move_right.as_slice()),
|
|
("move_up", self.editor.move_up.as_slice()),
|
|
("move_down", self.editor.move_down.as_slice()),
|
|
("move_word_left", self.editor.move_word_left.as_slice()),
|
|
("move_word_right", self.editor.move_word_right.as_slice()),
|
|
("move_line_start", self.editor.move_line_start.as_slice()),
|
|
("move_line_end", self.editor.move_line_end.as_slice()),
|
|
("delete_backward", self.editor.delete_backward.as_slice()),
|
|
("delete_forward", self.editor.delete_forward.as_slice()),
|
|
(
|
|
"delete_backward_word",
|
|
self.editor.delete_backward_word.as_slice(),
|
|
),
|
|
(
|
|
"delete_forward_word",
|
|
self.editor.delete_forward_word.as_slice(),
|
|
),
|
|
("kill_line_start", self.editor.kill_line_start.as_slice()),
|
|
("kill_whole_line", self.editor.kill_whole_line.as_slice()),
|
|
("kill_line_end", self.editor.kill_line_end.as_slice()),
|
|
("yank", self.editor.yank.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"vim_normal",
|
|
[
|
|
("enter_insert", self.vim_normal.enter_insert.as_slice()),
|
|
(
|
|
"append_after_cursor",
|
|
self.vim_normal.append_after_cursor.as_slice(),
|
|
),
|
|
(
|
|
"append_line_end",
|
|
self.vim_normal.append_line_end.as_slice(),
|
|
),
|
|
(
|
|
"insert_line_start",
|
|
self.vim_normal.insert_line_start.as_slice(),
|
|
),
|
|
(
|
|
"open_line_below",
|
|
self.vim_normal.open_line_below.as_slice(),
|
|
),
|
|
(
|
|
"open_line_above",
|
|
self.vim_normal.open_line_above.as_slice(),
|
|
),
|
|
("move_left", self.vim_normal.move_left.as_slice()),
|
|
("move_right", self.vim_normal.move_right.as_slice()),
|
|
("move_up", self.vim_normal.move_up.as_slice()),
|
|
("move_down", self.vim_normal.move_down.as_slice()),
|
|
(
|
|
"move_word_forward",
|
|
self.vim_normal.move_word_forward.as_slice(),
|
|
),
|
|
(
|
|
"move_word_backward",
|
|
self.vim_normal.move_word_backward.as_slice(),
|
|
),
|
|
("move_word_end", self.vim_normal.move_word_end.as_slice()),
|
|
(
|
|
"move_line_start",
|
|
self.vim_normal.move_line_start.as_slice(),
|
|
),
|
|
("move_line_end", self.vim_normal.move_line_end.as_slice()),
|
|
("delete_char", self.vim_normal.delete_char.as_slice()),
|
|
("replace_char", self.vim_normal.replace_char.as_slice()),
|
|
(
|
|
"substitute_char",
|
|
self.vim_normal.substitute_char.as_slice(),
|
|
),
|
|
(
|
|
"delete_to_line_end",
|
|
self.vim_normal.delete_to_line_end.as_slice(),
|
|
),
|
|
(
|
|
"change_to_line_end",
|
|
self.vim_normal.change_to_line_end.as_slice(),
|
|
),
|
|
("yank_line", self.vim_normal.yank_line.as_slice()),
|
|
("paste_after", self.vim_normal.paste_after.as_slice()),
|
|
(
|
|
"start_delete_operator",
|
|
self.vim_normal.start_delete_operator.as_slice(),
|
|
),
|
|
(
|
|
"start_yank_operator",
|
|
self.vim_normal.start_yank_operator.as_slice(),
|
|
),
|
|
(
|
|
"start_change_operator",
|
|
self.vim_normal.start_change_operator.as_slice(),
|
|
),
|
|
(
|
|
"cancel_operator",
|
|
self.vim_normal.cancel_operator.as_slice(),
|
|
),
|
|
],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"vim_operator",
|
|
[
|
|
("delete_line", self.vim_operator.delete_line.as_slice()),
|
|
("yank_line", self.vim_operator.yank_line.as_slice()),
|
|
("motion_left", self.vim_operator.motion_left.as_slice()),
|
|
("motion_right", self.vim_operator.motion_right.as_slice()),
|
|
("motion_up", self.vim_operator.motion_up.as_slice()),
|
|
("motion_down", self.vim_operator.motion_down.as_slice()),
|
|
(
|
|
"motion_word_forward",
|
|
self.vim_operator.motion_word_forward.as_slice(),
|
|
),
|
|
(
|
|
"motion_word_backward",
|
|
self.vim_operator.motion_word_backward.as_slice(),
|
|
),
|
|
(
|
|
"motion_word_end",
|
|
self.vim_operator.motion_word_end.as_slice(),
|
|
),
|
|
(
|
|
"motion_line_start",
|
|
self.vim_operator.motion_line_start.as_slice(),
|
|
),
|
|
(
|
|
"motion_line_end",
|
|
self.vim_operator.motion_line_end.as_slice(),
|
|
),
|
|
(
|
|
"select_inner_text_object",
|
|
self.vim_operator.select_inner_text_object.as_slice(),
|
|
),
|
|
(
|
|
"select_around_text_object",
|
|
self.vim_operator.select_around_text_object.as_slice(),
|
|
),
|
|
("cancel", self.vim_operator.cancel.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"vim_text_object",
|
|
[
|
|
("word", self.vim_text_object.word.as_slice()),
|
|
("big_word", self.vim_text_object.big_word.as_slice()),
|
|
("parentheses", self.vim_text_object.parentheses.as_slice()),
|
|
("brackets", self.vim_text_object.brackets.as_slice()),
|
|
("braces", self.vim_text_object.braces.as_slice()),
|
|
("double_quote", self.vim_text_object.double_quote.as_slice()),
|
|
("single_quote", self.vim_text_object.single_quote.as_slice()),
|
|
("backtick", self.vim_text_object.backtick.as_slice()),
|
|
("cancel", self.vim_text_object.cancel.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"pager",
|
|
[
|
|
("scroll_up", self.pager.scroll_up.as_slice()),
|
|
("scroll_down", self.pager.scroll_down.as_slice()),
|
|
("page_up", self.pager.page_up.as_slice()),
|
|
("page_down", self.pager.page_down.as_slice()),
|
|
("half_page_up", self.pager.half_page_up.as_slice()),
|
|
("half_page_down", self.pager.half_page_down.as_slice()),
|
|
("jump_top", self.pager.jump_top.as_slice()),
|
|
("jump_bottom", self.pager.jump_bottom.as_slice()),
|
|
("close", self.pager.close.as_slice()),
|
|
("close_transcript", self.pager.close_transcript.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
validate_no_reserved(
|
|
"pager",
|
|
[
|
|
("scroll_up", self.pager.scroll_up.as_slice()),
|
|
("scroll_down", self.pager.scroll_down.as_slice()),
|
|
("page_up", self.pager.page_up.as_slice()),
|
|
("page_down", self.pager.page_down.as_slice()),
|
|
("half_page_up", self.pager.half_page_up.as_slice()),
|
|
("half_page_down", self.pager.half_page_down.as_slice()),
|
|
("jump_top", self.pager.jump_top.as_slice()),
|
|
("jump_bottom", self.pager.jump_bottom.as_slice()),
|
|
("close", self.pager.close.as_slice()),
|
|
("close_transcript", self.pager.close_transcript.as_slice()),
|
|
],
|
|
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
|
|
[],
|
|
)?;
|
|
|
|
validate_unique(
|
|
"list",
|
|
[
|
|
("move_up", self.list.move_up.as_slice()),
|
|
("move_down", self.list.move_down.as_slice()),
|
|
("move_left", self.list.move_left.as_slice()),
|
|
("move_right", self.list.move_right.as_slice()),
|
|
("page_up", self.list.page_up.as_slice()),
|
|
("page_down", self.list.page_down.as_slice()),
|
|
("jump_top", self.list.jump_top.as_slice()),
|
|
("jump_bottom", self.list.jump_bottom.as_slice()),
|
|
("accept", self.list.accept.as_slice()),
|
|
("cancel", self.list.cancel.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
let agents_bindings = [
|
|
("search", self.agents.search.as_slice()),
|
|
("new_task", self.agents.new_task.as_slice()),
|
|
("rename", self.agents.rename.as_slice()),
|
|
("stop", self.agents.stop.as_slice()),
|
|
("toggle_grouping", self.agents.toggle_grouping.as_slice()),
|
|
];
|
|
validate_unique("agents", agents_bindings)?;
|
|
validate_no_reserved("agents", agents_bindings, MAIN_RESERVED_BINDINGS, [])?;
|
|
for (action, bindings) in agents_bindings {
|
|
#[cfg(unix)]
|
|
if bindings.contains(&key_hint::ctrl(KeyCode::Char('z'))) {
|
|
return Err(format!(
|
|
"tui.keymap.agents.{action}: ctrl-z is reserved for suspend"
|
|
));
|
|
}
|
|
if bindings.iter().any(|binding| {
|
|
matches!(binding.parts(), (KeyCode::Char(_), modifiers)
|
|
if modifiers.is_empty()
|
|
|| modifiers == KeyModifiers::SHIFT
|
|
|| crate::key_hint::is_altgr(modifiers))
|
|
|| binding.parts() == (KeyCode::Backspace, KeyModifiers::NONE)
|
|
}) {
|
|
return Err(format!(
|
|
"tui.keymap.agents.{action}: printable keys and backspace are reserved for task input"
|
|
));
|
|
}
|
|
}
|
|
|
|
validate_unique(
|
|
"approval",
|
|
[
|
|
("open_fullscreen", self.approval.open_fullscreen.as_slice()),
|
|
("open_thread", self.approval.open_thread.as_slice()),
|
|
("approve", self.approval.approve.as_slice()),
|
|
(
|
|
"approve_for_session",
|
|
self.approval.approve_for_session.as_slice(),
|
|
),
|
|
(
|
|
"approve_for_prefix",
|
|
self.approval.approve_for_prefix.as_slice(),
|
|
),
|
|
("deny", self.approval.deny.as_slice()),
|
|
("decline", self.approval.decline.as_slice()),
|
|
("cancel", self.approval.cancel.as_slice()),
|
|
],
|
|
)?;
|
|
|
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
|
for (action, bindings) in [
|
|
("list.move_up", self.list.move_up.as_slice()),
|
|
("list.move_down", self.list.move_down.as_slice()),
|
|
("list.move_left", self.list.move_left.as_slice()),
|
|
("list.move_right", self.list.move_right.as_slice()),
|
|
("list.page_up", self.list.page_up.as_slice()),
|
|
("list.page_down", self.list.page_down.as_slice()),
|
|
("list.jump_top", self.list.jump_top.as_slice()),
|
|
("list.jump_bottom", self.list.jump_bottom.as_slice()),
|
|
("list.accept", self.list.accept.as_slice()),
|
|
("list.cancel", self.list.cancel.as_slice()),
|
|
(
|
|
"approval.open_fullscreen",
|
|
self.approval.open_fullscreen.as_slice(),
|
|
),
|
|
("approval.open_thread", self.approval.open_thread.as_slice()),
|
|
("approval.approve", self.approval.approve.as_slice()),
|
|
(
|
|
"approval.approve_for_session",
|
|
self.approval.approve_for_session.as_slice(),
|
|
),
|
|
(
|
|
"approval.approve_for_prefix",
|
|
self.approval.approve_for_prefix.as_slice(),
|
|
),
|
|
("approval.deny", self.approval.deny.as_slice()),
|
|
("approval.decline", self.approval.decline.as_slice()),
|
|
("approval.cancel", self.approval.cancel.as_slice()),
|
|
] {
|
|
for binding in bindings {
|
|
let key = binding.parts();
|
|
if let Some(previous) = seen.insert(key, action) {
|
|
// Approval overlays intentionally reserve Esc as a stable
|
|
// cancellation path even though decline options may also
|
|
// display it in contexts where that is safe.
|
|
if previous == "list.cancel"
|
|
&& action == "approval.decline"
|
|
&& key == (KeyCode::Esc, KeyModifiers::NONE)
|
|
{
|
|
continue;
|
|
}
|
|
return Err(format!(
|
|
"Ambiguous approval overlay keymap bindings: `{previous}` and `{action}` use the same key. \
|
|
Set unique keys in `~/.codex/config.toml` and retry. \
|
|
See the Codex keymap documentation for supported actions and examples."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Reject duplicate keys inside one effective context map.
|
|
///
|
|
/// This intentionally allows the same key across different contexts; handlers
|
|
/// only evaluate one context at a time.
|
|
fn validate_unique<const N: usize>(
|
|
context: &str,
|
|
pairs: [(&'static str, &[KeyBinding]); N],
|
|
) -> Result<(), String> {
|
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
|
for (action, bindings) in pairs {
|
|
for binding in bindings {
|
|
let key = binding.parts();
|
|
if let Some(previous) = seen.insert(key, action) {
|
|
return Err(format!(
|
|
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \
|
|
Set unique keys in `~/.codex/config.toml` and retry. \
|
|
See the Codex keymap documentation for supported actions and examples."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_no_shadow_with_allowed_overlaps<const N: usize, const M: usize, const A: usize>(
|
|
context: &str,
|
|
primary: [(&'static str, &[KeyBinding]); N],
|
|
shadowed: [(&'static str, &[KeyBinding]); M],
|
|
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
|
|
) -> Result<(), String> {
|
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
|
for (action, bindings) in primary {
|
|
for binding in bindings {
|
|
seen.insert(binding.parts(), action);
|
|
}
|
|
}
|
|
for (action, bindings) in shadowed {
|
|
for binding in bindings {
|
|
let key = binding.parts();
|
|
if let Some(previous) = seen.get(&key) {
|
|
if allowed_overlaps.iter().any(
|
|
|(allowed_primary, allowed_shadowed, allowed_binding)| {
|
|
*allowed_primary == *previous
|
|
&& *allowed_shadowed == action
|
|
&& allowed_binding.parts() == key
|
|
},
|
|
) {
|
|
continue;
|
|
}
|
|
return Err(format!(
|
|
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` shadows `{action}` with the same key. \
|
|
Set unique keys in `~/.codex/config.toml` and retry. \
|
|
See the Codex keymap documentation for supported actions and examples."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_no_reserved<const N: usize, const A: usize>(
|
|
context: &str,
|
|
pairs: [(&'static str, &[KeyBinding]); N],
|
|
reserved: &[(&'static str, KeyBinding)],
|
|
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
|
|
) -> Result<(), String> {
|
|
for (action, bindings) in pairs {
|
|
for binding in bindings {
|
|
let key = binding.parts();
|
|
if let Some((reserved_action, _)) = reserved
|
|
.iter()
|
|
.find(|(_, reserved_binding)| reserved_binding.parts() == key)
|
|
{
|
|
if allowed_overlaps.iter().any(
|
|
|(allowed_action, allowed_reserved_action, allowed_binding)| {
|
|
*allowed_action == action
|
|
&& *allowed_reserved_action == *reserved_action
|
|
&& allowed_binding.parts() == key
|
|
},
|
|
) {
|
|
continue;
|
|
}
|
|
return Err(format!(
|
|
"Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \
|
|
Set a different key in `~/.codex/config.toml` and retry. \
|
|
See the Codex keymap documentation for supported actions and examples."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
|
(
|
|
"fixed.interrupt_or_quit",
|
|
key_hint::ctrl(KeyCode::Char('c')),
|
|
),
|
|
("fixed.quit", key_hint::ctrl(KeyCode::Char('d'))),
|
|
("fixed.paste_image", key_hint::ctrl(KeyCode::Char('v'))),
|
|
("fixed.paste_image", key_hint::ctrl_alt(KeyCode::Char('v'))),
|
|
(
|
|
"fixed.cycle_collaboration_mode",
|
|
key_hint::shift(KeyCode::Tab),
|
|
),
|
|
("fixed.backtrack", key_hint::plain(KeyCode::Esc)),
|
|
("fixed.previous_agent", key_hint::alt(KeyCode::Left)),
|
|
("fixed.next_agent", key_hint::alt(KeyCode::Right)),
|
|
("fixed.slash_command", key_hint::plain(KeyCode::Char('/'))),
|
|
("fixed.shell_command", key_hint::plain(KeyCode::Char('!'))),
|
|
("fixed.file_paths", key_hint::plain(KeyCode::Char('@'))),
|
|
(
|
|
"fixed.connector_mentions",
|
|
key_hint::plain(KeyCode::Char('$')),
|
|
),
|
|
];
|
|
|
|
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
|
(
|
|
"fixed.transcript_edit_previous",
|
|
key_hint::plain(KeyCode::Esc),
|
|
),
|
|
(
|
|
"fixed.transcript_edit_previous",
|
|
key_hint::plain(KeyCode::Left),
|
|
),
|
|
(
|
|
"fixed.transcript_edit_next",
|
|
key_hint::plain(KeyCode::Right),
|
|
),
|
|
(
|
|
"fixed.transcript_confirm_edit",
|
|
key_hint::plain(KeyCode::Enter),
|
|
),
|
|
];
|
|
|
|
/// Resolve one action with context -> global -> default precedence.
|
|
///
|
|
/// `path` should be the context-specific config path so parser errors point
|
|
/// users at the override they attempted to set.
|
|
///
|
|
/// A configured empty list is authoritative: it returns an empty binding set
|
|
/// and does not continue to the global or built-in fallback. This is what makes
|
|
/// explicit unbinding work for globally reusable actions like composer submit.
|
|
fn resolve_bindings_with_global_fallback(
|
|
configured: Option<&KeybindingsSpec>,
|
|
global: Option<&KeybindingsSpec>,
|
|
fallback: &[KeyBinding],
|
|
path: &str,
|
|
) -> Result<Vec<KeyBinding>, String> {
|
|
if let Some(configured) = configured {
|
|
return parse_bindings(configured, path);
|
|
}
|
|
if let Some(global) = global {
|
|
return parse_bindings(global, path);
|
|
}
|
|
Ok(fallback.to_vec())
|
|
}
|
|
|
|
/// Resolve one action binding in a context without global fallback.
|
|
///
|
|
/// Missing values inherit from the built-in fallback; configured values, including
|
|
/// empty lists, replace that fallback for the action.
|
|
fn resolve_bindings(
|
|
configured: Option<&KeybindingsSpec>,
|
|
fallback: &[KeyBinding],
|
|
path: &str,
|
|
) -> Result<Vec<KeyBinding>, String> {
|
|
let Some(spec) = configured else {
|
|
return Ok(fallback.to_vec());
|
|
};
|
|
parse_bindings(spec, path)
|
|
}
|
|
|
|
fn configured_bindings_to_preserve<const N: usize>(
|
|
pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N],
|
|
) -> Vec<KeyBinding> {
|
|
let mut configured_bindings = Vec::new();
|
|
for (configured, resolved) in pairs {
|
|
if configured.is_none() {
|
|
continue;
|
|
}
|
|
for binding in resolved {
|
|
if !configured_bindings.contains(binding) {
|
|
configured_bindings.push(*binding);
|
|
}
|
|
}
|
|
}
|
|
configured_bindings
|
|
}
|
|
|
|
fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> bool {
|
|
let mut global = keymap.global.clone();
|
|
if keymap.composer.submit.is_some() {
|
|
global.submit = None;
|
|
}
|
|
if keymap.composer.queue.is_some() {
|
|
global.queue = None;
|
|
}
|
|
if keymap.composer.toggle_shortcuts.is_some() {
|
|
global.toggle_shortcuts = None;
|
|
}
|
|
|
|
// Reasoning shortcuts run before composer/editor key handling, so fallback
|
|
// aliases must yield to any explicit binding on the same main-surface input
|
|
// path.
|
|
configured_context_alias_is_used(&global, alias)
|
|
|| configured_context_alias_is_used(&keymap.chat, alias)
|
|
|| configured_context_alias_is_used(&keymap.composer, alias)
|
|
|| configured_context_alias_is_used(&keymap.editor, alias)
|
|
|| configured_context_alias_is_used(&keymap.vim_normal, alias)
|
|
|| configured_context_alias_is_used(&keymap.vim_operator, alias)
|
|
|| configured_context_alias_is_used(&keymap.vim_text_object, alias)
|
|
}
|
|
|
|
fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool {
|
|
let Ok(value) = serde_json::to_value(context) else {
|
|
return false;
|
|
};
|
|
keymap_value_contains_alias(&value, alias)
|
|
}
|
|
|
|
fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
|
|
match value {
|
|
serde_json::Value::String(value) => value == alias,
|
|
serde_json::Value::Array(values) => values
|
|
.iter()
|
|
.any(|value| keymap_value_contains_alias(value, alias)),
|
|
serde_json::Value::Object(values) => values
|
|
.values()
|
|
.any(|value| keymap_value_contains_alias(value, alias)),
|
|
serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_new_default_bindings(
|
|
configured: Option<&KeybindingsSpec>,
|
|
fallback: &[KeyBinding],
|
|
configured_bindings_to_preserve: &[KeyBinding],
|
|
path: &str,
|
|
) -> Result<Vec<KeyBinding>, String> {
|
|
let Some(spec) = configured else {
|
|
return Ok(fallback
|
|
.iter()
|
|
.copied()
|
|
.filter(|binding| !configured_bindings_to_preserve.contains(binding))
|
|
.collect());
|
|
};
|
|
parse_bindings(spec, path)
|
|
}
|
|
|
|
/// Parse one keybinding value (`string` or `list[string]`) into concrete bindings.
|
|
///
|
|
/// Duplicate entries are de-duplicated while preserving first-seen order so the
|
|
/// first key can remain the primary UI hint.
|
|
fn parse_bindings(spec: &KeybindingsSpec, path: &str) -> Result<Vec<KeyBinding>, String> {
|
|
let mut parsed = Vec::new();
|
|
for raw in spec.specs() {
|
|
if raw.as_str().contains(' ') {
|
|
continue;
|
|
}
|
|
let binding = parse_keybinding(raw.as_str()).ok_or_else(|| {
|
|
format!(
|
|
"Invalid `{path}` = `{}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`. \
|
|
See the Codex keymap documentation for supported actions and examples.",
|
|
raw.as_str()
|
|
)
|
|
})?;
|
|
|
|
if !parsed.contains(&binding) {
|
|
parsed.push(binding);
|
|
}
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
/// Parse one normalized keybinding spec such as `ctrl-a` or `shift-enter`.
|
|
///
|
|
/// Specs are expected to be normalized by config deserialization, but this
|
|
/// parser remains strict to keep runtime error messages precise.
|
|
fn parse_keybinding(spec: &str) -> Option<KeyBinding> {
|
|
let mut parts = spec.split('-');
|
|
let mut modifiers = KeyModifiers::NONE;
|
|
let mut key_name = None;
|
|
|
|
for part in parts.by_ref() {
|
|
match part {
|
|
"ctrl" => modifiers |= KeyModifiers::CONTROL,
|
|
"alt" => modifiers |= KeyModifiers::ALT,
|
|
"shift" => modifiers |= KeyModifiers::SHIFT,
|
|
other => {
|
|
key_name = Some(other.to_string());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut key_name = key_name?;
|
|
for trailing in parts {
|
|
key_name.push('-');
|
|
key_name.push_str(trailing);
|
|
}
|
|
|
|
let key = match key_name.as_str() {
|
|
"enter" => KeyCode::Enter,
|
|
"tab" => KeyCode::Tab,
|
|
"backspace" => KeyCode::Backspace,
|
|
"esc" => KeyCode::Esc,
|
|
"delete" => KeyCode::Delete,
|
|
"up" => KeyCode::Up,
|
|
"down" => KeyCode::Down,
|
|
"left" => KeyCode::Left,
|
|
"right" => KeyCode::Right,
|
|
"home" => KeyCode::Home,
|
|
"end" => KeyCode::End,
|
|
"page-up" => KeyCode::PageUp,
|
|
"page-down" => KeyCode::PageDown,
|
|
"space" => KeyCode::Char(' '),
|
|
"minus" => KeyCode::Char('-'),
|
|
other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])),
|
|
other if other.starts_with('f') => {
|
|
let number = other[1..].parse::<u8>().ok()?;
|
|
if (1..=MAX_FUNCTION_KEY).contains(&number) {
|
|
KeyCode::F(number)
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
_ => return None,
|
|
};
|
|
|
|
Some(KeyBinding::new(key, modifiers))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_config::types::KeybindingSpec;
|
|
|
|
fn one(spec: &str) -> KeybindingsSpec {
|
|
KeybindingsSpec::One(KeybindingSpec(spec.to_string()))
|
|
}
|
|
|
|
fn expect_conflict(keymap: &TuiKeymap, first: &str, second: &str) {
|
|
let err = RuntimeKeymap::from_config(keymap).expect_err("expected conflict");
|
|
assert!(err.contains(first));
|
|
assert!(err.contains(second));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_canonical_binding() {
|
|
let binding = parse_keybinding("ctrl-alt-shift-a").expect("binding should parse");
|
|
assert_eq!(binding.parts().0, KeyCode::Char('a'));
|
|
assert_eq!(
|
|
binding.parts().1,
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_composer_binding_in_app_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("ctrl-t"));
|
|
keymap.composer.submit = Some(one("ctrl-t"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("composer.submit"));
|
|
assert!(err.contains("open_transcript"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_composer_queue_in_app_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_external_editor = Some(one("ctrl-g"));
|
|
keymap.composer.queue = Some(one("ctrl-g"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("composer.queue"));
|
|
assert!(err.contains("open_external_editor"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_composer_toggle_shortcuts_in_app_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("ctrl-k"));
|
|
keymap.composer.toggle_shortcuts = Some(one("ctrl-k"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("composer.toggle_shortcuts"));
|
|
assert!(err.contains("open_transcript"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_editor_binding_in_main_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.composer.submit = Some(one("ctrl-j"));
|
|
keymap.editor.insert_newline = Some(one("ctrl-j"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("composer.submit"));
|
|
assert!(err.contains("editor.insert_newline"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_editor_binding_from_outer_main_handler() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.copy = Some(one("ctrl-y"));
|
|
keymap.editor.yank = Some(one("ctrl-y"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("copy"));
|
|
assert!(err.contains("editor.yank"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_approval_binding_in_app_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("y"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("approval.approve"));
|
|
assert!(err.contains("open_transcript"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_shadowing_list_binding_in_app_scope() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.copy = Some(one("down"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict");
|
|
assert!(err.contains("list.move_down"));
|
|
assert!(err.contains("copy"));
|
|
}
|
|
|
|
#[test]
|
|
fn supports_string_or_array_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.composer.submit = Some(KeybindingsSpec::Many(vec![
|
|
KeybindingSpec("ctrl-enter".to_string()),
|
|
KeybindingSpec("meta-enter".to_string()),
|
|
]));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("meta is not a valid modifier");
|
|
assert!(err.contains("tui.keymap.composer.submit"));
|
|
|
|
keymap.composer.submit = Some(KeybindingsSpec::Many(vec![
|
|
KeybindingSpec("ctrl-enter".to_string()),
|
|
KeybindingSpec("ctrl-shift-enter".to_string()),
|
|
]));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding");
|
|
assert_eq!(runtime.composer.submit.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn deduplicates_repeated_bindings_while_preserving_first_seen_order() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.composer.submit = Some(KeybindingsSpec::Many(vec![
|
|
KeybindingSpec("ctrl-enter".to_string()),
|
|
KeybindingSpec("ctrl-enter".to_string()),
|
|
KeybindingSpec("ctrl-shift-enter".to_string()),
|
|
]));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding");
|
|
assert_eq!(
|
|
runtime.composer.submit,
|
|
vec![
|
|
key_hint::ctrl(KeyCode::Enter),
|
|
KeyBinding::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_global_binding_when_context_override_is_not_set() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.queue = Some(one("ctrl-q"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
assert_eq!(
|
|
runtime.composer.queue,
|
|
vec![key_hint::ctrl(KeyCode::Char('q'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_global_open_transcript_binding_reports_global_path() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("meta-t"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error");
|
|
assert!(err.contains("tui.keymap.global.open_transcript"));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_global_open_external_editor_binding_reports_global_path() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_external_editor = Some(one("meta-g"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error");
|
|
assert!(err.contains("tui.keymap.global.open_external_editor"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_copy_binding_is_ctrl_o() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_shortcuts_reserve_plain_text() {
|
|
for binding in ["a", "shift-a", "2", "space"] {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.chat.previous_permission_mode = Some(one(binding));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.expect_err("permission shortcuts must not intercept typing")
|
|
.contains("printable keys")
|
|
);
|
|
keymap.chat.previous_permission_mode = None;
|
|
keymap.chat.next_permission_mode = Some(one(binding));
|
|
assert!(RuntimeKeymap::from_config(&keymap).is_err());
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.chat.next_permission_mode = Some(one("ctrl-alt-q"));
|
|
assert!(RuntimeKeymap::from_config(&keymap).is_err());
|
|
}
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.chat.previous_permission_mode = Some(one("f7"));
|
|
keymap.chat.next_permission_mode = Some(one("ctrl-x enter"));
|
|
assert!(RuntimeKeymap::from_config(&keymap).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_include_reassignable_main_surface_actions() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
|
|
assert_eq!(
|
|
runtime.app.clear_terminal,
|
|
vec![key_hint::ctrl(KeyCode::Char('l'))]
|
|
);
|
|
assert_eq!(runtime.app.toggle_fast_mode, Vec::new());
|
|
assert_eq!(
|
|
runtime.chat.interrupt_turn,
|
|
vec![key_hint::plain(KeyCode::Esc)]
|
|
);
|
|
assert_eq!(
|
|
runtime.chat.decrease_reasoning_effort,
|
|
vec![
|
|
key_hint::alt(KeyCode::Char(',')),
|
|
key_hint::shift(KeyCode::Down),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.chat.increase_reasoning_effort,
|
|
vec![
|
|
key_hint::alt(KeyCode::Char('.')),
|
|
key_hint::shift(KeyCode::Up),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.chat.edit_queued_message,
|
|
vec![key_hint::alt(KeyCode::Up), key_hint::shift(KeyCode::Left)]
|
|
);
|
|
assert_eq!(
|
|
runtime.composer.history_search_previous,
|
|
vec![key_hint::ctrl(KeyCode::Char('r'))]
|
|
);
|
|
assert_eq!(
|
|
runtime.composer.history_search_next,
|
|
vec![key_hint::ctrl(KeyCode::Char('s'))]
|
|
);
|
|
assert_eq!(runtime.editor.kill_whole_line, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_include_list_page_and_jump_actions() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
|
|
assert_eq!(
|
|
runtime.list.move_up,
|
|
vec![
|
|
key_hint::plain(KeyCode::Up),
|
|
key_hint::ctrl(KeyCode::Char('p')),
|
|
key_hint::ctrl(KeyCode::Char('k')),
|
|
key_hint::plain(KeyCode::Char('k')),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.move_down,
|
|
vec![
|
|
key_hint::plain(KeyCode::Down),
|
|
key_hint::ctrl(KeyCode::Char('n')),
|
|
key_hint::ctrl(KeyCode::Char('j')),
|
|
key_hint::plain(KeyCode::Char('j')),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.move_left,
|
|
vec![
|
|
key_hint::plain(KeyCode::Left),
|
|
key_hint::ctrl(KeyCode::Char('h')),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.move_right,
|
|
vec![
|
|
key_hint::plain(KeyCode::Right),
|
|
key_hint::ctrl(KeyCode::Char('l')),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.page_up,
|
|
vec![
|
|
key_hint::plain(KeyCode::PageUp),
|
|
key_hint::ctrl(KeyCode::Char('b')),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.page_down,
|
|
vec![
|
|
key_hint::plain(KeyCode::PageDown),
|
|
key_hint::ctrl(KeyCode::Char('f')),
|
|
]
|
|
);
|
|
assert_eq!(runtime.list.jump_top, vec![key_hint::plain(KeyCode::Home)]);
|
|
assert_eq!(
|
|
runtime.list.jump_bottom,
|
|
vec![key_hint::plain(KeyCode::End)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_main_surface_bindings_prune_reasoning_fallback_aliases() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.editor.move_up = Some(one("shift-up"));
|
|
keymap.vim_text_object.word = Some(one("shift-down"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(runtime.editor.move_up, vec![key_hint::shift(KeyCode::Up)]);
|
|
assert_eq!(
|
|
runtime.vim_text_object.word,
|
|
vec![key_hint::shift(KeyCode::Down)]
|
|
);
|
|
assert_eq!(
|
|
runtime.chat.decrease_reasoning_effort,
|
|
vec![key_hint::alt(KeyCode::Char(','))]
|
|
);
|
|
assert_eq!(
|
|
runtime.chat.increase_reasoning_effort,
|
|
vec![key_hint::alt(KeyCode::Char('.'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_reasoning_binding_still_conflicts_with_editor_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.editor.move_up = Some(one("shift-up"));
|
|
keymap.chat.increase_reasoning_effort = Some(one("shift-up"));
|
|
|
|
expect_conflict(&keymap, "chat.increase_reasoning_effort", "editor.move_up");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_list_bindings_prune_new_default_overlaps() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.move_up = Some(one("page-up"));
|
|
keymap.list.move_down = Some(one("page-down"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(runtime.list.move_up, vec![key_hint::plain(KeyCode::PageUp)]);
|
|
assert_eq!(
|
|
runtime.list.move_down,
|
|
vec![key_hint::plain(KeyCode::PageDown)]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.page_up,
|
|
vec![key_hint::ctrl(KeyCode::Char('b'))]
|
|
);
|
|
assert_eq!(
|
|
runtime.list.page_down,
|
|
vec![key_hint::ctrl(KeyCode::Char('f'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_list_bindings_can_prune_all_new_default_keys() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.move_up = Some(KeybindingsSpec::Many(vec![
|
|
KeybindingSpec("page-up".to_string()),
|
|
KeybindingSpec("ctrl-b".to_string()),
|
|
]));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.list.move_up,
|
|
vec![
|
|
key_hint::plain(KeyCode::PageUp),
|
|
key_hint::ctrl(KeyCode::Char('b')),
|
|
]
|
|
);
|
|
assert_eq!(runtime.list.page_up, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_list_bindings_still_conflict_with_legacy_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.move_up = Some(one("page-up"));
|
|
keymap.list.page_up = Some(one("page-up"));
|
|
|
|
expect_conflict(&keymap, "move_up", "page_up");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_app_bindings_prune_new_list_default_overlaps() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.copy = Some(one("page-down"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(runtime.app.copy, vec![key_hint::plain(KeyCode::PageDown)]);
|
|
assert_eq!(
|
|
runtime.list.page_down,
|
|
vec![key_hint::ctrl(KeyCode::Char('f'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_approval_bindings_prune_new_list_default_overlaps() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.approval.approve = Some(one("home"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.approval.approve,
|
|
vec![key_hint::plain(KeyCode::Home)]
|
|
);
|
|
assert_eq!(runtime.list.jump_top, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_list_bindings_still_conflict_with_configured_approval_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.approval.approve = Some(one("home"));
|
|
keymap.list.jump_top = Some(one("home"));
|
|
|
|
expect_conflict(&keymap, "list.jump_top", "approval.approve");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("c"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.vim_normal.move_left,
|
|
vec![key_hint::plain(KeyCode::Char('c'))]
|
|
);
|
|
assert_eq!(runtime.vim_normal.start_change_operator, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("c"));
|
|
keymap.vim_normal.start_change_operator = Some(one("c"));
|
|
|
|
expect_conflict(&keymap, "move_left", "start_change_operator");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("s"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.vim_normal.move_left,
|
|
vec![key_hint::plain(KeyCode::Char('s'))]
|
|
);
|
|
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("s"));
|
|
keymap.vim_normal.substitute_char = Some(one("s"));
|
|
|
|
expect_conflict(&keymap, "move_left", "substitute_char");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_vim_normal_bindings_prune_new_replace_default() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("r"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.vim_normal.move_left,
|
|
vec![key_hint::plain(KeyCode::Char('r'))]
|
|
);
|
|
assert_eq!(runtime.vim_normal.replace_char, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn configured_substitute_binding_prunes_new_replace_default() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.substitute_char = Some(one("r"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.vim_normal.substitute_char,
|
|
vec![key_hint::plain(KeyCode::Char('r'))]
|
|
);
|
|
assert!(runtime.vim_normal.replace_char.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn configured_vim_normal_chord_prefix_prunes_new_replace_default() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_line_start = Some(one("r g"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert!(runtime.vim_normal.replace_char.is_empty());
|
|
assert!(runtime.chords.bindings.iter().any(|binding| {
|
|
binding.action.context == KeymapContext::VimNormal
|
|
&& binding.chord.prefix == key_hint::plain(KeyCode::Char('r'))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_vim_normal_replace_binding_still_conflicts_with_legacy_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_normal.move_left = Some(one("r"));
|
|
keymap.vim_normal.replace_char = Some(one("r"));
|
|
|
|
expect_conflict(&keymap, "move_left", "replace_char");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_operator.motion_left = Some(one("i"));
|
|
keymap.vim_operator.motion_right = Some(one("a"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.vim_operator.motion_left,
|
|
vec![key_hint::plain(KeyCode::Char('i'))]
|
|
);
|
|
assert_eq!(
|
|
runtime.vim_operator.motion_right,
|
|
vec![key_hint::plain(KeyCode::Char('a'))]
|
|
);
|
|
assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new());
|
|
assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.vim_operator.motion_left = Some(one("i"));
|
|
keymap.vim_operator.select_inner_text_object = Some(one("i"));
|
|
|
|
expect_conflict(&keymap, "motion_left", "select_inner_text_object");
|
|
}
|
|
|
|
#[test]
|
|
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
|
|
assert_eq!(
|
|
runtime.vim_normal.enter_insert,
|
|
vec![
|
|
key_hint::plain(KeyCode::Char('i')),
|
|
key_hint::plain(KeyCode::Insert)
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.vim_normal.move_left,
|
|
vec![
|
|
key_hint::plain(KeyCode::Char('h')),
|
|
key_hint::plain(KeyCode::Left)
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.vim_normal.move_right,
|
|
vec![
|
|
key_hint::plain(KeyCode::Char('l')),
|
|
key_hint::plain(KeyCode::Right)
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.vim_normal.move_up,
|
|
vec![
|
|
key_hint::plain(KeyCode::Char('k')),
|
|
key_hint::plain(KeyCode::Up)
|
|
]
|
|
);
|
|
assert_eq!(
|
|
runtime.vim_normal.move_down,
|
|
vec![
|
|
key_hint::plain(KeyCode::Char('j')),
|
|
key_hint::plain(KeyCode::Down)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_global_copy_binding_reports_global_path() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.copy = Some(one("meta-o"));
|
|
|
|
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error");
|
|
assert!(err.contains("tui.keymap.global.copy"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_editor_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.editor.move_left = Some(one("ctrl-h"));
|
|
keymap.editor.move_right = Some(one("ctrl-h"));
|
|
|
|
expect_conflict(&keymap, "move_left", "move_right");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_pager_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.pager.scroll_up = Some(one("ctrl-u"));
|
|
keymap.pager.scroll_down = Some(one("ctrl-u"));
|
|
|
|
expect_conflict(&keymap, "scroll_up", "scroll_down");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_list_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.move_up = Some(one("up"));
|
|
keymap.list.move_down = Some(one("up"));
|
|
|
|
expect_conflict(&keymap, "move_up", "move_down");
|
|
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.move_left = Some(one("left"));
|
|
keymap.list.move_right = Some(one("left"));
|
|
|
|
expect_conflict(&keymap, "move_left", "move_right");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_list_page_and_jump_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.page_up = Some(one("home"));
|
|
keymap.list.jump_top = Some(one("home"));
|
|
|
|
expect_conflict(&keymap, "page_up", "jump_top");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_approval_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.approval.approve = Some(one("y"));
|
|
keymap.approval.decline = Some(one("y"));
|
|
|
|
expect_conflict(&keymap, "approve", "decline");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_approval_deny_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.approval.approve = Some(one("y"));
|
|
keymap.approval.deny = Some(one("y"));
|
|
|
|
expect_conflict(&keymap, "approve", "deny");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_approval_overlay_accept_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.accept = Some(one("y"));
|
|
|
|
expect_conflict(&keymap, "list.accept", "approval.approve");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_approval_overlay_cancel_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.list.cancel = Some(one("c"));
|
|
|
|
expect_conflict(&keymap, "list.cancel", "approval.cancel");
|
|
}
|
|
|
|
#[test]
|
|
fn reassignable_fixed_shortcuts_conflict_until_original_action_is_unbound() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.copy = Some(one("alt-."));
|
|
|
|
expect_conflict(&keymap, "copy", "chat.increase_reasoning_effort");
|
|
|
|
keymap.chat.increase_reasoning_effort = Some(KeybindingsSpec::Many(vec![]));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free");
|
|
assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]);
|
|
}
|
|
|
|
#[test]
|
|
fn kill_whole_line_can_be_assigned_without_default_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.editor.kill_whole_line = Some(one("ctrl-shift-u"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
|
|
|
assert_eq!(
|
|
runtime.editor.kill_whole_line,
|
|
vec![KeyBinding::new(
|
|
KeyCode::Char('u'),
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.editor.kill_whole_line = Some(one("ctrl-u"));
|
|
|
|
expect_conflict(&keymap, "kill_line_start", "kill_whole_line");
|
|
|
|
keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![]));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free");
|
|
assert_eq!(
|
|
runtime.editor.kill_whole_line,
|
|
vec![key_hint::ctrl(KeyCode::Char('u'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_fast_mode_can_be_assigned_without_default_binding() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
|
|
|
assert_eq!(
|
|
runtime.app.toggle_fast_mode,
|
|
vec![KeyBinding::new(
|
|
KeyCode::Char('f'),
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.toggle_fast_mode = Some(one("ctrl-l"));
|
|
|
|
expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode");
|
|
}
|
|
|
|
#[test]
|
|
fn agents_overview_can_be_remapped_and_rejects_conflicts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_agents = Some(one("f12"));
|
|
keymap.agents.search = Some(one("f6"));
|
|
keymap.agents.new_task = Some(one("f7"));
|
|
keymap.agents.rename = Some(one("f9"));
|
|
keymap.agents.stop = Some(one("f10"));
|
|
keymap.agents.toggle_grouping = Some(one("f8"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
|
assert_eq!(
|
|
(
|
|
runtime.app.open_agents,
|
|
runtime.agents.search,
|
|
runtime.agents.new_task,
|
|
runtime.agents.rename,
|
|
runtime.agents.stop,
|
|
runtime.agents.toggle_grouping,
|
|
),
|
|
(
|
|
vec![key_hint::plain(KeyCode::F(12))],
|
|
vec![key_hint::plain(KeyCode::F(6))],
|
|
vec![key_hint::plain(KeyCode::F(7))],
|
|
vec![key_hint::plain(KeyCode::F(9))],
|
|
vec![key_hint::plain(KeyCode::F(10))],
|
|
vec![key_hint::plain(KeyCode::F(8))],
|
|
)
|
|
);
|
|
|
|
keymap.agents.toggle_grouping = Some(one("right"));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
|
assert!(
|
|
runtime
|
|
.list
|
|
.move_right
|
|
.contains(&key_hint::plain(KeyCode::Right))
|
|
);
|
|
keymap.agents.toggle_grouping = Some(one("f8"));
|
|
|
|
keymap.agents.stop = Some(one("f9"));
|
|
expect_conflict(&keymap, "rename", "stop");
|
|
|
|
keymap.agents.stop = Some(one("f10"));
|
|
keymap.global.open_agents = Some(one("ctrl-t"));
|
|
expect_conflict(&keymap, "open_agents", "open_transcript");
|
|
|
|
keymap.global.open_agents = Some(one("f12"));
|
|
keymap.agents.stop = Some(one("ctrl-c"));
|
|
expect_conflict(&keymap, "stop", "fixed.interrupt_or_quit");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
keymap.agents.stop = Some(one("ctrl-z"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.unwrap_err()
|
|
.contains("suspend")
|
|
);
|
|
keymap.agents.stop = Some(one("f10"));
|
|
keymap.global.open_agents = Some(one("ctrl-z"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.unwrap_err()
|
|
.contains("suspend")
|
|
);
|
|
keymap.global.open_agents = Some(one("f12"));
|
|
}
|
|
|
|
keymap.agents.stop = Some(one("f10"));
|
|
keymap.agents.search = Some(one("right"));
|
|
let runtime =
|
|
RuntimeKeymap::from_config(&keymap).expect("dashboard shortcuts take priority");
|
|
assert_eq!(runtime.agents.search, vec![key_hint::plain(KeyCode::Right)]);
|
|
assert_eq!(
|
|
runtime.list.move_right,
|
|
RuntimeKeymap::defaults().list.move_right
|
|
);
|
|
|
|
keymap.agents.search = None;
|
|
keymap.agents.rename = None;
|
|
keymap.list.move_right = Some(one("ctrl-r"));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("list shortcut remains usable");
|
|
assert_eq!(
|
|
runtime.agents.rename,
|
|
vec![key_hint::ctrl(KeyCode::Char('r'))]
|
|
);
|
|
|
|
keymap.agents.search = Some(one("s"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.expect_err("printable shortcut is reserved for input")
|
|
.contains("printable keys")
|
|
);
|
|
|
|
keymap.agents.search = None;
|
|
keymap.agents.stop = Some(one("backspace"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.expect_err("backspace is reserved for task input")
|
|
.contains("backspace")
|
|
);
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
keymap.agents.stop = Some(one("ctrl-alt-@"));
|
|
assert!(RuntimeKeymap::from_config(&keymap).is_err());
|
|
keymap.agents.stop = Some(one("f10"));
|
|
keymap.global.open_agents = Some(one("ctrl-alt-@"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.unwrap_err()
|
|
.contains("AltGr")
|
|
);
|
|
keymap.global.open_agents = Some(one("f12"));
|
|
}
|
|
keymap.agents.stop = Some(one("backspace f10"));
|
|
assert!(
|
|
RuntimeKeymap::from_config(&keymap)
|
|
.expect_err("backspace is reserved for task input")
|
|
.contains("backspace")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agents_overview_default_yields_to_existing_custom_shortcuts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("alt-a"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid");
|
|
|
|
assert_eq!(
|
|
runtime.app.open_transcript,
|
|
vec![key_hint::alt(KeyCode::Char('a'))]
|
|
);
|
|
assert!(runtime.app.open_agents.is_empty());
|
|
|
|
keymap.global.open_transcript = Some(one("alt-a ctrl-t"));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("existing chord remains valid");
|
|
assert!(runtime.app.open_agents.is_empty());
|
|
|
|
keymap.global.open_transcript = None;
|
|
keymap.pager.scroll_up = Some(one("alt-a page-up"));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("pager chord remains valid");
|
|
assert_eq!(
|
|
runtime.app.open_agents,
|
|
vec![key_hint::alt(KeyCode::Char('a'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.toggle_side_conversation = Some(one("f12"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
|
assert_eq!(
|
|
runtime.app.toggle_side_conversation,
|
|
vec![key_hint::plain(KeyCode::F(12))]
|
|
);
|
|
|
|
keymap.global.toggle_side_conversation = Some(one("ctrl-l"));
|
|
expect_conflict(&keymap, "clear_terminal", "toggle_side_conversation");
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_side_conversation_default_yields_to_existing_configured_bindings() {
|
|
for binding in ["ctrl-/", "ctrl-7"] {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one(binding));
|
|
|
|
let runtime =
|
|
RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid");
|
|
|
|
assert_eq!(
|
|
runtime.app.open_transcript,
|
|
vec![parse_keybinding(binding).expect("configured binding")]
|
|
);
|
|
assert!(runtime.app.toggle_side_conversation.is_empty());
|
|
}
|
|
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("ctrl-/"));
|
|
keymap.global.copy = Some(one("ctrl-7"));
|
|
|
|
let runtime =
|
|
RuntimeKeymap::from_config(&keymap).expect("distinct legacy bindings remain valid");
|
|
assert!(runtime.app.toggle_side_conversation.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_side_toggle_rejects_legacy_ctrl_slash_alias_conflicts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.open_transcript = Some(one("ctrl-7"));
|
|
keymap.global.toggle_side_conversation = Some(one("ctrl-/"));
|
|
|
|
expect_conflict(&keymap, "open_transcript", "toggle_side_conversation");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.composer.submit = Some(one("ctrl-v"));
|
|
|
|
expect_conflict(&keymap, "composer.submit", "fixed.paste_image");
|
|
}
|
|
|
|
#[test]
|
|
fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() {
|
|
let mut keymap = TuiKeymap::default();
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse");
|
|
assert_eq!(
|
|
runtime.chat.interrupt_turn,
|
|
vec![key_hint::plain(KeyCode::Esc)]
|
|
);
|
|
|
|
keymap.chat.interrupt_turn = Some(one("f12"));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse");
|
|
assert_eq!(
|
|
runtime.chat.interrupt_turn,
|
|
vec![key_hint::plain(KeyCode::F(12))]
|
|
);
|
|
|
|
keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![]));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse");
|
|
assert!(runtime.chat.interrupt_turn.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn interrupt_turn_rejects_other_fixed_shortcuts() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.chat.interrupt_turn = Some(one("ctrl-v"));
|
|
|
|
expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image");
|
|
}
|
|
|
|
#[test]
|
|
fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.chat.interrupt_turn = Some(one("f12"));
|
|
keymap.list.move_right = Some(one("f12"));
|
|
|
|
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.pager.close = Some(one("left"));
|
|
|
|
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_function_keys_and_rejects_out_of_range_function_keys() {
|
|
assert_eq!(
|
|
parse_keybinding("f1").map(|binding| binding.parts()),
|
|
Some((KeyCode::F(1), KeyModifiers::NONE))
|
|
);
|
|
assert_eq!(
|
|
parse_keybinding("f24").map(|binding| binding.parts()),
|
|
Some((KeyCode::F(24), KeyModifiers::NONE))
|
|
);
|
|
assert_eq!(parse_keybinding("f25"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_all_named_non_character_keys() {
|
|
let cases = [
|
|
("tab", KeyCode::Tab),
|
|
("backspace", KeyCode::Backspace),
|
|
("esc", KeyCode::Esc),
|
|
("delete", KeyCode::Delete),
|
|
("up", KeyCode::Up),
|
|
("down", KeyCode::Down),
|
|
("left", KeyCode::Left),
|
|
("right", KeyCode::Right),
|
|
("home", KeyCode::Home),
|
|
("end", KeyCode::End),
|
|
("page-up", KeyCode::PageUp),
|
|
("page-down", KeyCode::PageDown),
|
|
("space", KeyCode::Char(' ')),
|
|
("minus", KeyCode::Char('-')),
|
|
];
|
|
|
|
for (spec, expected_key) in cases {
|
|
assert_eq!(
|
|
parse_keybinding(spec).map(|binding| binding.parts()),
|
|
Some((expected_key, KeyModifiers::NONE)),
|
|
"failed to parse {spec}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_modifier_only_and_nonnumeric_function_key_specs() {
|
|
assert_eq!(parse_keybinding("ctrl"), None);
|
|
assert_eq!(parse_keybinding("ff"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_minus_alias_and_legacy_literal_minus() {
|
|
assert_eq!(
|
|
parse_keybinding("alt-minus").map(|binding| binding.parts()),
|
|
Some((KeyCode::Char('-'), KeyModifiers::ALT))
|
|
);
|
|
assert_eq!(
|
|
parse_keybinding("alt--").map(|binding| binding.parts()),
|
|
Some((KeyCode::Char('-'), KeyModifiers::ALT))
|
|
);
|
|
assert_eq!(
|
|
parse_keybinding("-").map(|binding| binding.parts()),
|
|
Some((KeyCode::Char('-'), KeyModifiers::NONE))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_empty_array_unbinds_action() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![]));
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
assert!(runtime.composer.toggle_shortcuts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn raw_output_toggle_defaults_to_alt_r() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert_eq!(
|
|
runtime.app.toggle_raw_output,
|
|
vec![key_hint::alt(KeyCode::Char('r'))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn raw_output_toggle_can_be_remapped() {
|
|
let mut keymap = TuiKeymap::default();
|
|
keymap.global.toggle_raw_output = Some(one("f12"));
|
|
|
|
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
|
|
|
assert_eq!(
|
|
runtime.app.toggle_raw_output,
|
|
vec![key_hint::plain(KeyCode::F(12))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_editor_insert_newline_includes_current_aliases() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert_eq!(
|
|
runtime.editor.insert_newline,
|
|
vec![
|
|
key_hint::ctrl(KeyCode::Char('j')),
|
|
key_hint::ctrl(KeyCode::Char('m')),
|
|
key_hint::plain(KeyCode::Enter),
|
|
key_hint::shift(KeyCode::Enter),
|
|
key_hint::alt(KeyCode::Enter),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_editor_delete_forward_word_includes_alt_d() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_forward_word
|
|
.contains(&key_hint::alt(KeyCode::Char('d')))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_editor_deletion_includes_modified_backspace_delete_aliases() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_backward
|
|
.contains(&key_hint::shift(KeyCode::Backspace))
|
|
);
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_forward
|
|
.contains(&key_hint::shift(KeyCode::Delete))
|
|
);
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_backward_word
|
|
.contains(&key_hint::ctrl(KeyCode::Backspace))
|
|
);
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_backward_word
|
|
.contains(&KeyBinding::new(
|
|
KeyCode::Backspace,
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
|
))
|
|
);
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_forward_word
|
|
.contains(&key_hint::ctrl(KeyCode::Delete))
|
|
);
|
|
assert!(
|
|
runtime
|
|
.editor
|
|
.delete_forward_word
|
|
.contains(&KeyBinding::new(
|
|
KeyCode::Delete,
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_composer_toggle_shortcuts_includes_shift_question_mark() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert!(
|
|
runtime
|
|
.composer
|
|
.toggle_shortcuts
|
|
.contains(&key_hint::shift(KeyCode::Char('?')))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_approval_open_fullscreen_includes_ctrl_shift_a() {
|
|
let runtime = RuntimeKeymap::defaults();
|
|
assert!(runtime.approval.open_fullscreen.contains(&KeyBinding::new(
|
|
KeyCode::Char('a'),
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn primary_binding_returns_first_or_none() {
|
|
let bindings = vec![
|
|
key_hint::ctrl(KeyCode::Char('a')),
|
|
key_hint::shift(KeyCode::Char('b')),
|
|
];
|
|
assert_eq!(
|
|
primary_binding(&bindings),
|
|
Some(key_hint::ctrl(KeyCode::Char('a')))
|
|
);
|
|
assert_eq!(primary_binding(&[]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_pass_conflict_validation() {
|
|
RuntimeKeymap::defaults()
|
|
.validate_conflicts()
|
|
.expect("default keymap should be conflict free");
|
|
}
|
|
}
|