//! 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 codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use std::collections::HashMap;

/// 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) chat: ChatKeymap,
    pub(crate) composer: ComposerKeymap,
    pub(crate) editor: EditorKeymap,
    pub(crate) vim_normal: VimNormalKeymap,
    pub(crate) vim_operator: VimOperatorKeymap,
    pub(crate) pager: PagerKeymap,
    pub(crate) list: ListKeymap,
    pub(crate) approval: ApprovalKeymap,
}

#[derive(Clone, Debug)]
pub(crate) struct AppKeymap {
    /// 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>,
}

/// 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 {
    /// Decrease the active reasoning effort.
    pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
    /// Increase the active reasoning effort.
    pub(crate) increase_reasoning_effort: 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 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) delete_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) cancel_operator: Vec<KeyBinding>,
}

/// Vim operator-pending keybindings active after `d` or `y` in normal mode.
///
/// When an operator (`start_delete_operator` or `start_yank_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) 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>,
}

/// 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>,
}

/// 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>,
}

/// 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> {
    bindings.first().copied()
}

/// 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 app = AppKeymap {
            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",
            )?,
        };

        let chat = ChatKeymap {
            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",
            )?,
            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 = 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 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),
            delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_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),
            cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
        };

        let 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),
            cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
        };

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

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

        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 mut configured_bindings_to_preserve = Vec::new();
        for (configured, resolved) in [
            (
                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.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()),
        ] {
            if configured.is_none() {
                continue;
            }
            for binding in resolved {
                if !configured_bindings_to_preserve.contains(binding) {
                    configured_bindings_to_preserve.push(*binding);
                }
            }
        }

        let list = ListKeymap {
            move_up: list_move_up,
            move_down: list_move_down,
            move_left: resolve_new_list_bindings(
                keymap.list.move_left.as_ref(),
                &defaults.list.move_left,
                &configured_bindings_to_preserve,
                "tui.keymap.list.move_left",
            )?,
            move_right: resolve_new_list_bindings(
                keymap.list.move_right.as_ref(),
                &defaults.list.move_right,
                &configured_bindings_to_preserve,
                "tui.keymap.list.move_right",
            )?,
            page_up: resolve_new_list_bindings(
                keymap.list.page_up.as_ref(),
                &defaults.list.page_up,
                &configured_bindings_to_preserve,
                "tui.keymap.list.page_up",
            )?,
            page_down: resolve_new_list_bindings(
                keymap.list.page_down.as_ref(),
                &defaults.list.page_down,
                &configured_bindings_to_preserve,
                "tui.keymap.list.page_down",
            )?,
            jump_top: resolve_new_list_bindings(
                keymap.list.jump_top.as_ref(),
                &defaults.list.jump_top,
                &configured_bindings_to_preserve,
                "tui.keymap.list.jump_top",
            )?,
            jump_bottom: resolve_new_list_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,
        };

        let resolved = Self {
            app,
            chat,
            composer,
            editor,
            vim_normal,
            vim_operator,
            pager,
            list,
            approval,
        };

        resolved.validate_conflicts()?;
        Ok(resolved)
    }

    /// 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_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'))],
            },
            chat: ChatKeymap {
                decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
                increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))],
                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: 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'))],
                delete_to_line_end: default_bindings![
                    shift(KeyCode::Char('d')),
                    plain(KeyCode::Char('D'))
                ],
                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'))],
                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('$'))
                ],
                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'))],
            },
            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)],
            },
            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'))],
            },
        }
    }

    /// 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> {
        validate_unique(
            "app",
            [
                ("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()),
                (
                    "chat.decrease_reasoning_effort",
                    self.chat.decrease_reasoning_effort.as_slice(),
                ),
                (
                    "chat.increase_reasoning_effort",
                    self.chat.increase_reasoning_effort.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_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()),
                (
                    "chat.decrease_reasoning_effort",
                    self.chat.decrease_reasoning_effort.as_slice(),
                ),
                (
                    "chat.increase_reasoning_effort",
                    self.chat.increase_reasoning_effort.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,
        )?;

        validate_no_shadow_with_allowed_overlaps(
            "app",
            [
                ("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()),
            ],
            [
                ("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')),
            )],
        )?;

        // 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_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.decrease_reasoning_effort",
                    self.chat.decrease_reasoning_effort.as_slice(),
                ),
                (
                    "chat.increase_reasoning_effort",
                    self.chat.increase_reasoning_effort.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()),
                (
                    "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()),
                (
                    "delete_to_line_end",
                    self.vim_normal.delete_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(),
                ),
                (
                    "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(),
                ),
                ("cancel", self.vim_operator.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()),
            ],
        )?;

        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>(
    context: &str,
    pairs: [(&'static str, &[KeyBinding]); N],
    reserved: &[(&'static str, KeyBinding)],
) -> 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)
            {
                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.return_from_side_or_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 resolve_new_list_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() {
        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..=12).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 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.decrease_reasoning_effort,
            vec![key_hint::alt(KeyCode::Char(','))]
        );
        assert_eq!(
            runtime.chat.increase_reasoning_effort,
            vec![key_hint::alt(KeyCode::Char('.'))]
        );
        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_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 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 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 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("f13"), 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");
    }
}
