use crate::tui_internal::key_hint;
use crate::tui_internal::key_hint::KeyBinding;
use lemurclaw_core::config::types::KeybindingsSpec;
use lemurclaw_core::config::types::MAX_FUNCTION_KEY;
use lemurclaw_core::config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use serde::Serialize;
use std::collections::HashMap;
#[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) vim_text_object: VimTextObjectKeymap,
pub(crate) pager: PagerKeymap,
pub(crate) list: ListKeymap,
pub(crate) approval: ApprovalKeymap,
}
#[derive(Clone, Debug)]
pub(crate) struct AppKeymap {
pub(crate) open_transcript: Vec<KeyBinding>,
pub(crate) open_external_editor: Vec<KeyBinding>,
pub(crate) copy: Vec<KeyBinding>,
pub(crate) clear_terminal: Vec<KeyBinding>,
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
pub(crate) toggle_raw_output: Vec<KeyBinding>,
}
#[derive(Clone, Debug)]
pub(crate) struct ChatKeymap {
pub(crate) interrupt_turn: Vec<KeyBinding>,
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
pub(crate) increase_reasoning_effort: Vec<KeyBinding>,
pub(crate) edit_queued_message: Vec<KeyBinding>,
}
#[derive(Clone, Debug)]
pub(crate) struct ComposerKeymap {
pub(crate) submit: Vec<KeyBinding>,
pub(crate) queue: Vec<KeyBinding>,
pub(crate) toggle_shortcuts: Vec<KeyBinding>,
pub(crate) history_search_previous: Vec<KeyBinding>,
pub(crate) history_search_next: Vec<KeyBinding>,
}
#[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>,
}
#[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) substitute_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) paste_after: Vec<KeyBinding>,
pub(crate) start_delete_operator: Vec<KeyBinding>,
pub(crate) start_yank_operator: Vec<KeyBinding>,
pub(crate) start_change_operator: Vec<KeyBinding>,
pub(crate) cancel_operator: Vec<KeyBinding>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct VimOperatorKeymap {
pub(crate) delete_line: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) motion_left: Vec<KeyBinding>,
pub(crate) motion_right: Vec<KeyBinding>,
pub(crate) motion_up: Vec<KeyBinding>,
pub(crate) motion_down: Vec<KeyBinding>,
pub(crate) motion_word_forward: Vec<KeyBinding>,
pub(crate) motion_word_backward: Vec<KeyBinding>,
pub(crate) motion_word_end: Vec<KeyBinding>,
pub(crate) motion_line_start: Vec<KeyBinding>,
pub(crate) motion_line_end: Vec<KeyBinding>,
pub(crate) select_inner_text_object: Vec<KeyBinding>,
pub(crate) select_around_text_object: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct VimTextObjectKeymap {
pub(crate) word: Vec<KeyBinding>,
pub(crate) big_word: Vec<KeyBinding>,
pub(crate) parentheses: Vec<KeyBinding>,
pub(crate) brackets: Vec<KeyBinding>,
pub(crate) braces: Vec<KeyBinding>,
pub(crate) double_quote: Vec<KeyBinding>,
pub(crate) single_quote: Vec<KeyBinding>,
pub(crate) backtick: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
#[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>,
}
#[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>,
}
#[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>,
}
pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option<KeyBinding> {
bindings.first().copied()
}
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)
),
)?
};
}
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)
),
)?
};
}
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
};
}
macro_rules! default_bindings {
($($kind:ident($($arg:tt)*)),* $(,)?) => {
vec![$(default_binding!($kind($($arg)*))),*]
};
}
impl RuntimeKeymap {
pub(crate) fn defaults() -> Self {
Self::built_in_defaults()
}
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 mut chat = ChatKeymap {
interrupt_turn: resolve_bindings(
keymap.chat.interrupt_turn.as_ref(),
&defaults.chat.interrupt_turn,
"tui.keymap.chat.interrupt_turn",
)?,
decrease_reasoning_effort: resolve_bindings(
keymap.chat.decrease_reasoning_effort.as_ref(),
&defaults.chat.decrease_reasoning_effort,
"tui.keymap.chat.decrease_reasoning_effort",
)?,
increase_reasoning_effort: resolve_bindings(
keymap.chat.increase_reasoning_effort.as_ref(),
&defaults.chat.increase_reasoning_effort,
"tui.keymap.chat.increase_reasoning_effort",
)?,
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 mut vim_normal = VimNormalKeymap {
enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert),
append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor),
append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end),
insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start),
open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below),
open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above),
move_left: resolve_local!(keymap, defaults, vim_normal, move_left),
move_right: resolve_local!(keymap, defaults, vim_normal, move_right),
move_up: resolve_local!(keymap, defaults, vim_normal, move_up),
move_down: resolve_local!(keymap, defaults, vim_normal, move_down),
move_word_forward: resolve_local!(keymap, defaults, vim_normal, move_word_forward),
move_word_backward: resolve_local!(keymap, defaults, vim_normal, move_word_backward),
move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end),
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
start_delete_operator: resolve_local!(
keymap,
defaults,
vim_normal,
start_delete_operator
),
start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator),
start_change_operator: resolve_local!(
keymap,
defaults,
vim_normal,
start_change_operator
),
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
};
let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.vim_normal.enter_insert.as_ref(),
vim_normal.enter_insert.as_slice(),
),
(
keymap.vim_normal.append_after_cursor.as_ref(),
vim_normal.append_after_cursor.as_slice(),
),
(
keymap.vim_normal.append_line_end.as_ref(),
vim_normal.append_line_end.as_slice(),
),
(
keymap.vim_normal.insert_line_start.as_ref(),
vim_normal.insert_line_start.as_slice(),
),
(
keymap.vim_normal.open_line_below.as_ref(),
vim_normal.open_line_below.as_slice(),
),
(
keymap.vim_normal.open_line_above.as_ref(),
vim_normal.open_line_above.as_slice(),
),
(
keymap.vim_normal.move_left.as_ref(),
vim_normal.move_left.as_slice(),
),
(
keymap.vim_normal.move_right.as_ref(),
vim_normal.move_right.as_slice(),
),
(
keymap.vim_normal.move_up.as_ref(),
vim_normal.move_up.as_slice(),
),
(
keymap.vim_normal.move_down.as_ref(),
vim_normal.move_down.as_slice(),
),
(
keymap.vim_normal.move_word_forward.as_ref(),
vim_normal.move_word_forward.as_slice(),
),
(
keymap.vim_normal.move_word_backward.as_ref(),
vim_normal.move_word_backward.as_slice(),
),
(
keymap.vim_normal.move_word_end.as_ref(),
vim_normal.move_word_end.as_slice(),
),
(
keymap.vim_normal.move_line_start.as_ref(),
vim_normal.move_line_start.as_slice(),
),
(
keymap.vim_normal.move_line_end.as_ref(),
vim_normal.move_line_end.as_slice(),
),
(
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
),
(
keymap.vim_normal.change_to_line_end.as_ref(),
vim_normal.change_to_line_end.as_slice(),
),
(
keymap.vim_normal.delete_to_line_end.as_ref(),
vim_normal.delete_to_line_end.as_slice(),
),
(
keymap.vim_normal.yank_line.as_ref(),
vim_normal.yank_line.as_slice(),
),
(
keymap.vim_normal.paste_after.as_ref(),
vim_normal.paste_after.as_slice(),
),
(
keymap.vim_normal.start_delete_operator.as_ref(),
vim_normal.start_delete_operator.as_slice(),
),
(
keymap.vim_normal.start_yank_operator.as_ref(),
vim_normal.start_yank_operator.as_slice(),
),
(
keymap.vim_normal.start_change_operator.as_ref(),
vim_normal.start_change_operator.as_slice(),
),
(
keymap.vim_normal.cancel_operator.as_ref(),
vim_normal.cancel_operator.as_slice(),
),
]);
if keymap.vim_normal.start_change_operator.is_none() {
vim_normal
.start_change_operator
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.substitute_char.is_none() {
vim_normal
.substitute_char
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
let mut vim_operator = VimOperatorKeymap {
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left),
motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right),
motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up),
motion_down: resolve_local!(keymap, defaults, vim_operator, motion_down),
motion_word_forward: resolve_local!(
keymap,
defaults,
vim_operator,
motion_word_forward
),
motion_word_backward: resolve_local!(
keymap,
defaults,
vim_operator,
motion_word_backward
),
motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end),
motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start),
motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end),
select_inner_text_object: resolve_local!(
keymap,
defaults,
vim_operator,
select_inner_text_object
),
select_around_text_object: resolve_local!(
keymap,
defaults,
vim_operator,
select_around_text_object
),
cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
};
let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.vim_operator.delete_line.as_ref(),
vim_operator.delete_line.as_slice(),
),
(
keymap.vim_operator.yank_line.as_ref(),
vim_operator.yank_line.as_slice(),
),
(
keymap.vim_operator.motion_left.as_ref(),
vim_operator.motion_left.as_slice(),
),
(
keymap.vim_operator.motion_right.as_ref(),
vim_operator.motion_right.as_slice(),
),
(
keymap.vim_operator.motion_up.as_ref(),
vim_operator.motion_up.as_slice(),
),
(
keymap.vim_operator.motion_down.as_ref(),
vim_operator.motion_down.as_slice(),
),
(
keymap.vim_operator.motion_word_forward.as_ref(),
vim_operator.motion_word_forward.as_slice(),
),
(
keymap.vim_operator.motion_word_backward.as_ref(),
vim_operator.motion_word_backward.as_slice(),
),
(
keymap.vim_operator.motion_word_end.as_ref(),
vim_operator.motion_word_end.as_slice(),
),
(
keymap.vim_operator.motion_line_start.as_ref(),
vim_operator.motion_line_start.as_slice(),
),
(
keymap.vim_operator.motion_line_end.as_ref(),
vim_operator.motion_line_end.as_slice(),
),
(
keymap.vim_operator.cancel.as_ref(),
vim_operator.cancel.as_slice(),
),
]);
if keymap.vim_operator.select_inner_text_object.is_none() {
vim_operator
.select_inner_text_object
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.select_around_text_object.is_none() {
vim_operator
.select_around_text_object
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
let vim_text_object = VimTextObjectKeymap {
word: resolve_local!(keymap, defaults, vim_text_object, word),
big_word: resolve_local!(keymap, defaults, vim_text_object, big_word),
parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses),
brackets: resolve_local!(keymap, defaults, vim_text_object, brackets),
braces: resolve_local!(keymap, defaults, vim_text_object, braces),
double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote),
single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote),
backtick: resolve_local!(keymap, defaults, vim_text_object, backtick),
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
};
if keymap.chat.decrease_reasoning_effort.is_none()
&& configured_main_surface_alias_is_used(keymap, "shift-down")
{
chat.decrease_reasoning_effort
.retain(|binding| *binding != key_hint::shift(KeyCode::Down));
}
if keymap.chat.increase_reasoning_effort.is_none()
&& configured_main_surface_alias_is_used(keymap, "shift-up")
{
chat.increase_reasoning_effort
.retain(|binding| *binding != key_hint::shift(KeyCode::Up));
}
let pager = PagerKeymap {
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
page_up: resolve_local!(keymap, defaults, pager, page_up),
page_down: resolve_local!(keymap, defaults, pager, page_down),
half_page_up: resolve_local!(keymap, defaults, pager, half_page_up),
half_page_down: resolve_local!(keymap, defaults, pager, half_page_down),
jump_top: resolve_local!(keymap, defaults, pager, jump_top),
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
};
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 configured_bindings_to_preserve = configured_bindings_to_preserve([
(
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()),
]);
let list = ListKeymap {
move_up: list_move_up,
move_down: list_move_down,
move_left: resolve_new_default_bindings(
keymap.list.move_left.as_ref(),
&defaults.list.move_left,
&configured_bindings_to_preserve,
"tui.keymap.list.move_left",
)?,
move_right: resolve_new_default_bindings(
keymap.list.move_right.as_ref(),
&defaults.list.move_right,
&configured_bindings_to_preserve,
"tui.keymap.list.move_right",
)?,
page_up: resolve_new_default_bindings(
keymap.list.page_up.as_ref(),
&defaults.list.page_up,
&configured_bindings_to_preserve,
"tui.keymap.list.page_up",
)?,
page_down: resolve_new_default_bindings(
keymap.list.page_down.as_ref(),
&defaults.list.page_down,
&configured_bindings_to_preserve,
"tui.keymap.list.page_down",
)?,
jump_top: resolve_new_default_bindings(
keymap.list.jump_top.as_ref(),
&defaults.list.jump_top,
&configured_bindings_to_preserve,
"tui.keymap.list.jump_top",
)?,
jump_bottom: resolve_new_default_bindings(
keymap.list.jump_bottom.as_ref(),
&defaults.list.jump_bottom,
&configured_bindings_to_preserve,
"tui.keymap.list.jump_bottom",
)?,
accept: list_accept,
cancel: list_cancel,
};
let resolved = Self {
app,
chat,
composer,
editor,
vim_normal,
vim_operator,
vim_text_object,
pager,
list,
approval,
};
resolved.validate_conflicts()?;
Ok(resolved)
}
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 {
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
decrease_reasoning_effort: default_bindings![
alt(KeyCode::Char(',')),
shift(KeyCode::Down)
],
increase_reasoning_effort: default_bindings![
alt(KeyCode::Char('.')),
shift(KeyCode::Up)
],
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'))],
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
plain(KeyCode::Char('D'))
],
change_to_line_end: default_bindings![
shift(KeyCode::Char('c')),
plain(KeyCode::Char('C'))
],
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
paste_after: default_bindings![plain(KeyCode::Char('p'))],
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
cancel_operator: default_bindings![plain(KeyCode::Esc)],
},
vim_operator: VimOperatorKeymap {
delete_line: default_bindings![plain(KeyCode::Char('d'))],
yank_line: default_bindings![plain(KeyCode::Char('y'))],
motion_left: default_bindings![plain(KeyCode::Char('h'))],
motion_right: default_bindings![plain(KeyCode::Char('l'))],
motion_up: default_bindings![plain(KeyCode::Char('k'))],
motion_down: default_bindings![plain(KeyCode::Char('j'))],
motion_word_forward: default_bindings![plain(KeyCode::Char('w'))],
motion_word_backward: default_bindings![plain(KeyCode::Char('b'))],
motion_word_end: default_bindings![plain(KeyCode::Char('e'))],
motion_line_start: default_bindings![plain(KeyCode::Char('0'))],
motion_line_end: default_bindings![
plain(KeyCode::Char('$')),
shift(KeyCode::Char('$'))
],
select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))],
select_around_text_object: default_bindings![plain(KeyCode::Char('a'))],
cancel: default_bindings![plain(KeyCode::Esc)],
},
vim_text_object: VimTextObjectKeymap {
word: default_bindings![plain(KeyCode::Char('w'))],
big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))],
parentheses: default_bindings![
plain(KeyCode::Char('(')),
shift(KeyCode::Char('(')),
plain(KeyCode::Char(')')),
shift(KeyCode::Char(')')),
plain(KeyCode::Char('b'))
],
brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))],
braces: default_bindings![
plain(KeyCode::Char('{')),
shift(KeyCode::Char('{')),
plain(KeyCode::Char('}')),
shift(KeyCode::Char('}')),
shift(KeyCode::Char('b')),
plain(KeyCode::Char('B'))
],
double_quote: default_bindings![
plain(KeyCode::Char('"')),
shift(KeyCode::Char('"'))
],
single_quote: default_bindings![plain(KeyCode::Char('\''))],
backtick: default_bindings![plain(KeyCode::Char('`'))],
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))],
scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))],
page_up: default_bindings![
plain(KeyCode::PageUp),
shift(KeyCode::Char(' ')),
ctrl(KeyCode::Char('b'))
],
page_down: default_bindings![
plain(KeyCode::PageDown),
plain(KeyCode::Char(' ')),
ctrl(KeyCode::Char('f'))
],
half_page_up: default_bindings![ctrl(KeyCode::Char('u'))],
half_page_down: default_bindings![ctrl(KeyCode::Char('d'))],
jump_top: default_bindings![plain(KeyCode::Home)],
jump_bottom: default_bindings![plain(KeyCode::End)],
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
},
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'))],
},
}
}
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.interrupt_turn", self.chat.interrupt_turn.as_slice()),
(
"chat.decrease_reasoning_effort",
self.chat.decrease_reasoning_effort.as_slice(),
),
(
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
(
"chat.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.interrupt_turn", self.chat.interrupt_turn.as_slice()),
(
"chat.decrease_reasoning_effort",
self.chat.decrease_reasoning_effort.as_slice(),
),
(
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
(
"chat.edit_queued_message",
self.chat.edit_queued_message.as_slice(),
),
("composer.submit", self.composer.submit.as_slice()),
("composer.queue", self.composer.queue.as_slice()),
(
"composer.toggle_shortcuts",
self.composer.toggle_shortcuts.as_slice(),
),
(
"composer.history_search_previous",
self.composer.history_search_previous.as_slice(),
),
(
"composer.history_search_next",
self.composer.history_search_next.as_slice(),
),
],
MAIN_RESERVED_BINDINGS,
[(
"chat.interrupt_turn",
"fixed.backtrack",
key_hint::plain(KeyCode::Esc),
)],
)?;
validate_no_shadow_with_allowed_overlaps(
"app",
[
("open_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')),
)],
)?;
validate_no_shadow_with_allowed_overlaps(
"request_user_input",
[("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())],
[
("list.move_left", self.list.move_left.as_slice()),
("list.move_right", self.list.move_right.as_slice()),
],
[],
)?;
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.interrupt_turn", self.chat.interrupt_turn.as_slice()),
(
"chat.decrease_reasoning_effort",
self.chat.decrease_reasoning_effort.as_slice(),
),
(
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
("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()),
(
"substitute_char",
self.vim_normal.substitute_char.as_slice(),
),
(
"delete_to_line_end",
self.vim_normal.delete_to_line_end.as_slice(),
),
(
"change_to_line_end",
self.vim_normal.change_to_line_end.as_slice(),
),
("yank_line", self.vim_normal.yank_line.as_slice()),
("paste_after", self.vim_normal.paste_after.as_slice()),
(
"start_delete_operator",
self.vim_normal.start_delete_operator.as_slice(),
),
(
"start_yank_operator",
self.vim_normal.start_yank_operator.as_slice(),
),
(
"start_change_operator",
self.vim_normal.start_change_operator.as_slice(),
),
(
"cancel_operator",
self.vim_normal.cancel_operator.as_slice(),
),
],
)?;
validate_unique(
"vim_operator",
[
("delete_line", self.vim_operator.delete_line.as_slice()),
("yank_line", self.vim_operator.yank_line.as_slice()),
("motion_left", self.vim_operator.motion_left.as_slice()),
("motion_right", self.vim_operator.motion_right.as_slice()),
("motion_up", self.vim_operator.motion_up.as_slice()),
("motion_down", self.vim_operator.motion_down.as_slice()),
(
"motion_word_forward",
self.vim_operator.motion_word_forward.as_slice(),
),
(
"motion_word_backward",
self.vim_operator.motion_word_backward.as_slice(),
),
(
"motion_word_end",
self.vim_operator.motion_word_end.as_slice(),
),
(
"motion_line_start",
self.vim_operator.motion_line_start.as_slice(),
),
(
"motion_line_end",
self.vim_operator.motion_line_end.as_slice(),
),
(
"select_inner_text_object",
self.vim_operator.select_inner_text_object.as_slice(),
),
(
"select_around_text_object",
self.vim_operator.select_around_text_object.as_slice(),
),
("cancel", self.vim_operator.cancel.as_slice()),
],
)?;
validate_unique(
"vim_text_object",
[
("word", self.vim_text_object.word.as_slice()),
("big_word", self.vim_text_object.big_word.as_slice()),
("parentheses", self.vim_text_object.parentheses.as_slice()),
("brackets", self.vim_text_object.brackets.as_slice()),
("braces", self.vim_text_object.braces.as_slice()),
("double_quote", self.vim_text_object.double_quote.as_slice()),
("single_quote", self.vim_text_object.single_quote.as_slice()),
("backtick", self.vim_text_object.backtick.as_slice()),
("cancel", self.vim_text_object.cancel.as_slice()),
],
)?;
validate_unique(
"pager",
[
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
("page_down", self.pager.page_down.as_slice()),
("half_page_up", self.pager.half_page_up.as_slice()),
("half_page_down", self.pager.half_page_down.as_slice()),
("jump_top", self.pager.jump_top.as_slice()),
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
],
)?;
validate_no_reserved(
"pager",
[
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
("page_down", self.pager.page_down.as_slice()),
("half_page_up", self.pager.half_page_up.as_slice()),
("half_page_down", self.pager.half_page_down.as_slice()),
("jump_top", self.pager.jump_top.as_slice()),
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
],
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
[],
)?;
validate_unique(
"list",
[
("move_up", self.list.move_up.as_slice()),
("move_down", self.list.move_down.as_slice()),
("move_left", self.list.move_left.as_slice()),
("move_right", self.list.move_right.as_slice()),
("page_up", self.list.page_up.as_slice()),
("page_down", self.list.page_down.as_slice()),
("jump_top", self.list.jump_top.as_slice()),
("jump_bottom", self.list.jump_bottom.as_slice()),
("accept", self.list.accept.as_slice()),
("cancel", self.list.cancel.as_slice()),
],
)?;
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) {
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(())
}
}
fn validate_unique<const N: usize>(
context: &str,
pairs: [(&'static str, &[KeyBinding]); N],
) -> Result<(), String> {
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
for (action, bindings) in pairs {
for binding in bindings {
let key = binding.parts();
if let Some(previous) = seen.insert(key, action) {
return Err(format!(
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \
Set unique keys in `~/.codex/config.toml` and retry. \
See the Codex keymap documentation for supported actions and examples."
));
}
}
}
Ok(())
}
fn validate_no_shadow_with_allowed_overlaps<const N: usize, const M: usize, const A: usize>(
context: &str,
primary: [(&'static str, &[KeyBinding]); N],
shadowed: [(&'static str, &[KeyBinding]); M],
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
) -> Result<(), String> {
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
for (action, bindings) in primary {
for binding in bindings {
seen.insert(binding.parts(), action);
}
}
for (action, bindings) in shadowed {
for binding in bindings {
let key = binding.parts();
if let Some(previous) = seen.get(&key) {
if allowed_overlaps.iter().any(
|(allowed_primary, allowed_shadowed, allowed_binding)| {
*allowed_primary == *previous
&& *allowed_shadowed == action
&& allowed_binding.parts() == key
},
) {
continue;
}
return Err(format!(
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` shadows `{action}` with the same key. \
Set unique keys in `~/.codex/config.toml` and retry. \
See the Codex keymap documentation for supported actions and examples."
));
}
}
}
Ok(())
}
fn validate_no_reserved<const N: usize, const A: usize>(
context: &str,
pairs: [(&'static str, &[KeyBinding]); N],
reserved: &[(&'static str, KeyBinding)],
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
) -> Result<(), String> {
for (action, bindings) in pairs {
for binding in bindings {
let key = binding.parts();
if let Some((reserved_action, _)) = reserved
.iter()
.find(|(_, reserved_binding)| reserved_binding.parts() == key)
{
if allowed_overlaps.iter().any(
|(allowed_action, allowed_reserved_action, allowed_binding)| {
*allowed_action == action
&& *allowed_reserved_action == *reserved_action
&& allowed_binding.parts() == key
},
) {
continue;
}
return Err(format!(
"Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \
Set a different key in `~/.codex/config.toml` and retry. \
See the Codex keymap documentation for supported actions and examples."
));
}
}
}
Ok(())
}
const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
(
"fixed.interrupt_or_quit",
key_hint::ctrl(KeyCode::Char('c')),
),
("fixed.quit", key_hint::ctrl(KeyCode::Char('d'))),
("fixed.paste_image", key_hint::ctrl(KeyCode::Char('v'))),
("fixed.paste_image", key_hint::ctrl_alt(KeyCode::Char('v'))),
(
"fixed.cycle_collaboration_mode",
key_hint::shift(KeyCode::Tab),
),
("fixed.backtrack", key_hint::plain(KeyCode::Esc)),
("fixed.previous_agent", key_hint::alt(KeyCode::Left)),
("fixed.next_agent", key_hint::alt(KeyCode::Right)),
("fixed.slash_command", key_hint::plain(KeyCode::Char('/'))),
("fixed.shell_command", key_hint::plain(KeyCode::Char('!'))),
("fixed.file_paths", key_hint::plain(KeyCode::Char('@'))),
(
"fixed.connector_mentions",
key_hint::plain(KeyCode::Char('$')),
),
];
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
),
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Left),
),
(
"fixed.transcript_edit_next",
key_hint::plain(KeyCode::Right),
),
(
"fixed.transcript_confirm_edit",
key_hint::plain(KeyCode::Enter),
),
];
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())
}
fn resolve_bindings(
configured: Option<&KeybindingsSpec>,
fallback: &[KeyBinding],
path: &str,
) -> Result<Vec<KeyBinding>, String> {
let Some(spec) = configured else {
return Ok(fallback.to_vec());
};
parse_bindings(spec, path)
}
fn configured_bindings_to_preserve<const N: usize>(
pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N],
) -> Vec<KeyBinding> {
let mut configured_bindings = Vec::new();
for (configured, resolved) in pairs {
if configured.is_none() {
continue;
}
for binding in resolved {
if !configured_bindings.contains(binding) {
configured_bindings.push(*binding);
}
}
}
configured_bindings
}
fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> bool {
let mut global = keymap.global.clone();
if keymap.composer.submit.is_some() {
global.submit = None;
}
if keymap.composer.queue.is_some() {
global.queue = None;
}
if keymap.composer.toggle_shortcuts.is_some() {
global.toggle_shortcuts = None;
}
configured_context_alias_is_used(&global, alias)
|| configured_context_alias_is_used(&keymap.chat, alias)
|| configured_context_alias_is_used(&keymap.composer, alias)
|| configured_context_alias_is_used(&keymap.editor, alias)
|| configured_context_alias_is_used(&keymap.vim_normal, alias)
|| configured_context_alias_is_used(&keymap.vim_operator, alias)
|| configured_context_alias_is_used(&keymap.vim_text_object, alias)
}
fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool {
let Ok(value) = serde_json::to_value(context) else {
return false;
};
keymap_value_contains_alias(&value, alias)
}
fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
match value {
serde_json::Value::String(value) => value == alias,
serde_json::Value::Array(values) => values
.iter()
.any(|value| keymap_value_contains_alias(value, alias)),
serde_json::Value::Object(values) => values
.values()
.any(|value| keymap_value_contains_alias(value, alias)),
serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => {
false
}
}
}
fn resolve_new_default_bindings(
configured: Option<&KeybindingsSpec>,
fallback: &[KeyBinding],
configured_bindings_to_preserve: &[KeyBinding],
path: &str,
) -> Result<Vec<KeyBinding>, String> {
let Some(spec) = configured else {
return Ok(fallback
.iter()
.copied()
.filter(|binding| !configured_bindings_to_preserve.contains(binding))
.collect());
};
parse_bindings(spec, path)
}
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)
}
fn parse_keybinding(spec: &str) -> Option<KeyBinding> {
let mut parts = spec.split('-');
let mut modifiers = KeyModifiers::NONE;
let mut key_name = None;
for part in parts.by_ref() {
match part {
"ctrl" => modifiers |= KeyModifiers::CONTROL,
"alt" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
other => {
key_name = Some(other.to_string());
break;
}
}
}
let mut key_name = key_name?;
for trailing in parts {
key_name.push('-');
key_name.push_str(trailing);
}
let key = match key_name.as_str() {
"enter" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backspace" => KeyCode::Backspace,
"esc" => KeyCode::Esc,
"delete" => KeyCode::Delete,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"page-up" => KeyCode::PageUp,
"page-down" => KeyCode::PageDown,
"space" => KeyCode::Char(' '),
"minus" => KeyCode::Char('-'),
other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])),
other if other.starts_with('f') => {
let number = other[1..].parse::<u8>().ok()?;
if (1..=MAX_FUNCTION_KEY).contains(&number) {
KeyCode::F(number)
} else {
return None;
}
}
_ => return None,
};
Some(KeyBinding::new(key, modifiers))
}
#[cfg(test)]
mod tests {
use super::*;
use lemurclaw_core::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.interrupt_turn,
vec![key_hint::plain(KeyCode::Esc)]
);
assert_eq!(
runtime.chat.decrease_reasoning_effort,
vec![
key_hint::alt(KeyCode::Char(',')),
key_hint::shift(KeyCode::Down),
]
);
assert_eq!(
runtime.chat.increase_reasoning_effort,
vec![
key_hint::alt(KeyCode::Char('.')),
key_hint::shift(KeyCode::Up),
]
);
assert_eq!(
runtime.chat.edit_queued_message,
vec![key_hint::alt(KeyCode::Up), key_hint::shift(KeyCode::Left)]
);
assert_eq!(
runtime.composer.history_search_previous,
vec![key_hint::ctrl(KeyCode::Char('r'))]
);
assert_eq!(
runtime.composer.history_search_next,
vec![key_hint::ctrl(KeyCode::Char('s'))]
);
assert_eq!(runtime.editor.kill_whole_line, Vec::new());
}
#[test]
fn defaults_include_list_page_and_jump_actions() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.list.move_up,
vec![
key_hint::plain(KeyCode::Up),
key_hint::ctrl(KeyCode::Char('p')),
key_hint::ctrl(KeyCode::Char('k')),
key_hint::plain(KeyCode::Char('k')),
]
);
assert_eq!(
runtime.list.move_down,
vec![
key_hint::plain(KeyCode::Down),
key_hint::ctrl(KeyCode::Char('n')),
key_hint::ctrl(KeyCode::Char('j')),
key_hint::plain(KeyCode::Char('j')),
]
);
assert_eq!(
runtime.list.move_left,
vec![
key_hint::plain(KeyCode::Left),
key_hint::ctrl(KeyCode::Char('h')),
]
);
assert_eq!(
runtime.list.move_right,
vec![
key_hint::plain(KeyCode::Right),
key_hint::ctrl(KeyCode::Char('l')),
]
);
assert_eq!(
runtime.list.page_up,
vec![
key_hint::plain(KeyCode::PageUp),
key_hint::ctrl(KeyCode::Char('b')),
]
);
assert_eq!(
runtime.list.page_down,
vec![
key_hint::plain(KeyCode::PageDown),
key_hint::ctrl(KeyCode::Char('f')),
]
);
assert_eq!(runtime.list.jump_top, vec![key_hint::plain(KeyCode::Home)]);
assert_eq!(
runtime.list.jump_bottom,
vec![key_hint::plain(KeyCode::End)]
);
}
#[test]
fn configured_main_surface_bindings_prune_reasoning_fallback_aliases() {
let mut keymap = TuiKeymap::default();
keymap.editor.move_up = Some(one("shift-up"));
keymap.vim_text_object.word = Some(one("shift-down"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.editor.move_up, vec![key_hint::shift(KeyCode::Up)]);
assert_eq!(
runtime.vim_text_object.word,
vec![key_hint::shift(KeyCode::Down)]
);
assert_eq!(
runtime.chat.decrease_reasoning_effort,
vec![key_hint::alt(KeyCode::Char(','))]
);
assert_eq!(
runtime.chat.increase_reasoning_effort,
vec![key_hint::alt(KeyCode::Char('.'))]
);
}
#[test]
fn explicit_reasoning_binding_still_conflicts_with_editor_binding() {
let mut keymap = TuiKeymap::default();
keymap.editor.move_up = Some(one("shift-up"));
keymap.chat.increase_reasoning_effort = Some(one("shift-up"));
expect_conflict(&keymap, "chat.increase_reasoning_effort", "editor.move_up");
}
#[test]
fn configured_legacy_list_bindings_prune_new_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(one("page-up"));
keymap.list.move_down = Some(one("page-down"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.list.move_up, vec![key_hint::plain(KeyCode::PageUp)]);
assert_eq!(
runtime.list.move_down,
vec![key_hint::plain(KeyCode::PageDown)]
);
assert_eq!(
runtime.list.page_up,
vec![key_hint::ctrl(KeyCode::Char('b'))]
);
assert_eq!(
runtime.list.page_down,
vec![key_hint::ctrl(KeyCode::Char('f'))]
);
}
#[test]
fn configured_legacy_list_bindings_can_prune_all_new_default_keys() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(KeybindingsSpec::Many(vec![
KeybindingSpec("page-up".to_string()),
KeybindingSpec("ctrl-b".to_string()),
]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.list.move_up,
vec![
key_hint::plain(KeyCode::PageUp),
key_hint::ctrl(KeyCode::Char('b')),
]
);
assert_eq!(runtime.list.page_up, Vec::new());
}
#[test]
fn explicit_new_list_bindings_still_conflict_with_legacy_bindings() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(one("page-up"));
keymap.list.page_up = Some(one("page-up"));
expect_conflict(&keymap, "move_up", "page_up");
}
#[test]
fn configured_app_bindings_prune_new_list_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.global.copy = Some(one("page-down"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.app.copy, vec![key_hint::plain(KeyCode::PageDown)]);
assert_eq!(
runtime.list.page_down,
vec![key_hint::ctrl(KeyCode::Char('f'))]
);
}
#[test]
fn configured_approval_bindings_prune_new_list_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("home"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.approval.approve,
vec![key_hint::plain(KeyCode::Home)]
);
assert_eq!(runtime.list.jump_top, Vec::new());
}
#[test]
fn explicit_new_list_bindings_still_conflict_with_configured_approval_bindings() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("home"));
keymap.list.jump_top = Some(one("home"));
expect_conflict(&keymap, "list.jump_top", "approval.approve");
}
#[test]
fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("c"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.move_left,
vec![key_hint::plain(KeyCode::Char('c'))]
);
assert_eq!(runtime.vim_normal.start_change_operator, Vec::new());
}
#[test]
fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("c"));
keymap.vim_normal.start_change_operator = Some(one("c"));
expect_conflict(&keymap, "move_left", "start_change_operator");
}
#[test]
fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("s"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.move_left,
vec![key_hint::plain(KeyCode::Char('s'))]
);
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
}
#[test]
fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("s"));
keymap.vim_normal.substitute_char = Some(one("s"));
expect_conflict(&keymap, "move_left", "substitute_char");
}
#[test]
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_operator.motion_left = Some(one("i"));
keymap.vim_operator.motion_right = Some(one("a"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_operator.motion_left,
vec![key_hint::plain(KeyCode::Char('i'))]
);
assert_eq!(
runtime.vim_operator.motion_right,
vec![key_hint::plain(KeyCode::Char('a'))]
);
assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new());
assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new());
}
#[test]
fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_operator.motion_left = Some(one("i"));
keymap.vim_operator.select_inner_text_object = Some(one("i"));
expect_conflict(&keymap, "motion_left", "select_inner_text_object");
}
#[test]
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.vim_normal.enter_insert,
vec![
key_hint::plain(KeyCode::Char('i')),
key_hint::plain(KeyCode::Insert)
]
);
assert_eq!(
runtime.vim_normal.move_left,
vec![
key_hint::plain(KeyCode::Char('h')),
key_hint::plain(KeyCode::Left)
]
);
assert_eq!(
runtime.vim_normal.move_right,
vec![
key_hint::plain(KeyCode::Char('l')),
key_hint::plain(KeyCode::Right)
]
);
assert_eq!(
runtime.vim_normal.move_up,
vec![
key_hint::plain(KeyCode::Char('k')),
key_hint::plain(KeyCode::Up)
]
);
assert_eq!(
runtime.vim_normal.move_down,
vec![
key_hint::plain(KeyCode::Char('j')),
key_hint::plain(KeyCode::Down)
]
);
}
#[test]
fn invalid_global_copy_binding_reports_global_path() {
let mut keymap = TuiKeymap::default();
keymap.global.copy = Some(one("meta-o"));
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error");
assert!(err.contains("tui.keymap.global.copy"));
}
#[test]
fn rejects_conflicting_editor_bindings() {
let mut keymap = TuiKeymap::default();
keymap.editor.move_left = Some(one("ctrl-h"));
keymap.editor.move_right = Some(one("ctrl-h"));
expect_conflict(&keymap, "move_left", "move_right");
}
#[test]
fn rejects_conflicting_pager_bindings() {
let mut keymap = TuiKeymap::default();
keymap.pager.scroll_up = Some(one("ctrl-u"));
keymap.pager.scroll_down = Some(one("ctrl-u"));
expect_conflict(&keymap, "scroll_up", "scroll_down");
}
#[test]
fn rejects_conflicting_list_bindings() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(one("up"));
keymap.list.move_down = Some(one("up"));
expect_conflict(&keymap, "move_up", "move_down");
let mut keymap = TuiKeymap::default();
keymap.list.move_left = Some(one("left"));
keymap.list.move_right = Some(one("left"));
expect_conflict(&keymap, "move_left", "move_right");
}
#[test]
fn rejects_conflicting_list_page_and_jump_bindings() {
let mut keymap = TuiKeymap::default();
keymap.list.page_up = Some(one("home"));
keymap.list.jump_top = Some(one("home"));
expect_conflict(&keymap, "page_up", "jump_top");
}
#[test]
fn rejects_conflicting_approval_bindings() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("y"));
keymap.approval.decline = Some(one("y"));
expect_conflict(&keymap, "approve", "decline");
}
#[test]
fn rejects_conflicting_approval_deny_binding() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("y"));
keymap.approval.deny = Some(one("y"));
expect_conflict(&keymap, "approve", "deny");
}
#[test]
fn rejects_conflicting_approval_overlay_accept_binding() {
let mut keymap = TuiKeymap::default();
keymap.list.accept = Some(one("y"));
expect_conflict(&keymap, "list.accept", "approval.approve");
}
#[test]
fn rejects_conflicting_approval_overlay_cancel_binding() {
let mut keymap = TuiKeymap::default();
keymap.list.cancel = Some(one("c"));
expect_conflict(&keymap, "list.cancel", "approval.cancel");
}
#[test]
fn reassignable_fixed_shortcuts_conflict_until_original_action_is_unbound() {
let mut keymap = TuiKeymap::default();
keymap.global.copy = Some(one("alt-."));
expect_conflict(&keymap, "copy", "chat.increase_reasoning_effort");
keymap.chat.increase_reasoning_effort = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free");
assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]);
}
#[test]
fn kill_whole_line_can_be_assigned_without_default_binding() {
let mut keymap = TuiKeymap::default();
keymap.editor.kill_whole_line = Some(one("ctrl-shift-u"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert_eq!(
runtime.editor.kill_whole_line,
vec![KeyBinding::new(
KeyCode::Char('u'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)]
);
}
#[test]
fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() {
let mut keymap = TuiKeymap::default();
keymap.editor.kill_whole_line = Some(one("ctrl-u"));
expect_conflict(&keymap, "kill_line_start", "kill_whole_line");
keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free");
assert_eq!(
runtime.editor.kill_whole_line,
vec![key_hint::ctrl(KeyCode::Char('u'))]
);
}
#[test]
fn toggle_fast_mode_can_be_assigned_without_default_binding() {
let mut keymap = TuiKeymap::default();
keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert_eq!(
runtime.app.toggle_fast_mode,
vec![KeyBinding::new(
KeyCode::Char('f'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)]
);
}
#[test]
fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() {
let mut keymap = TuiKeymap::default();
keymap.global.toggle_fast_mode = Some(one("ctrl-l"));
expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode");
}
#[test]
fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() {
let mut keymap = TuiKeymap::default();
keymap.composer.submit = Some(one("ctrl-v"));
expect_conflict(&keymap, "composer.submit", "fixed.paste_image");
}
#[test]
fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() {
let mut keymap = TuiKeymap::default();
let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse");
assert_eq!(
runtime.chat.interrupt_turn,
vec![key_hint::plain(KeyCode::Esc)]
);
keymap.chat.interrupt_turn = Some(one("f12"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse");
assert_eq!(
runtime.chat.interrupt_turn,
vec![key_hint::plain(KeyCode::F(12))]
);
keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse");
assert!(runtime.chat.interrupt_turn.is_empty());
}
#[test]
fn interrupt_turn_rejects_other_fixed_shortcuts() {
let mut keymap = TuiKeymap::default();
keymap.chat.interrupt_turn = Some(one("ctrl-v"));
expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image");
}
#[test]
fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() {
let mut keymap = TuiKeymap::default();
keymap.chat.interrupt_turn = Some(one("f12"));
keymap.list.move_right = Some(one("f12"));
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right");
}
#[test]
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
let mut keymap = TuiKeymap::default();
keymap.pager.close = Some(one("left"));
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
}
#[test]
fn parses_function_keys_and_rejects_out_of_range_function_keys() {
assert_eq!(
parse_keybinding("f1").map(|binding| binding.parts()),
Some((KeyCode::F(1), KeyModifiers::NONE))
);
assert_eq!(
parse_keybinding("f24").map(|binding| binding.parts()),
Some((KeyCode::F(24), KeyModifiers::NONE))
);
assert_eq!(parse_keybinding("f25"), None);
}
#[test]
fn parses_all_named_non_character_keys() {
let cases = [
("tab", KeyCode::Tab),
("backspace", KeyCode::Backspace),
("esc", KeyCode::Esc),
("delete", KeyCode::Delete),
("up", KeyCode::Up),
("down", KeyCode::Down),
("left", KeyCode::Left),
("right", KeyCode::Right),
("home", KeyCode::Home),
("end", KeyCode::End),
("page-up", KeyCode::PageUp),
("page-down", KeyCode::PageDown),
("space", KeyCode::Char(' ')),
("minus", KeyCode::Char('-')),
];
for (spec, expected_key) in cases {
assert_eq!(
parse_keybinding(spec).map(|binding| binding.parts()),
Some((expected_key, KeyModifiers::NONE)),
"failed to parse {spec}"
);
}
}
#[test]
fn rejects_modifier_only_and_nonnumeric_function_key_specs() {
assert_eq!(parse_keybinding("ctrl"), None);
assert_eq!(parse_keybinding("ff"), None);
}
#[test]
fn parses_minus_alias_and_legacy_literal_minus() {
assert_eq!(
parse_keybinding("alt-minus").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::ALT))
);
assert_eq!(
parse_keybinding("alt--").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::ALT))
);
assert_eq!(
parse_keybinding("-").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::NONE))
);
}
#[test]
fn explicit_empty_array_unbinds_action() {
let mut keymap = TuiKeymap::default();
keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert!(runtime.composer.toggle_shortcuts.is_empty());
}
#[test]
fn raw_output_toggle_defaults_to_alt_r() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.app.toggle_raw_output,
vec![key_hint::alt(KeyCode::Char('r'))]
);
}
#[test]
fn raw_output_toggle_can_be_remapped() {
let mut keymap = TuiKeymap::default();
keymap.global.toggle_raw_output = Some(one("f12"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.app.toggle_raw_output,
vec![key_hint::plain(KeyCode::F(12))]
);
}
#[test]
fn default_editor_insert_newline_includes_current_aliases() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.editor.insert_newline,
vec![
key_hint::ctrl(KeyCode::Char('j')),
key_hint::ctrl(KeyCode::Char('m')),
key_hint::plain(KeyCode::Enter),
key_hint::shift(KeyCode::Enter),
key_hint::alt(KeyCode::Enter),
]
);
}
#[test]
fn default_editor_delete_forward_word_includes_alt_d() {
let runtime = RuntimeKeymap::defaults();
assert!(
runtime
.editor
.delete_forward_word
.contains(&key_hint::alt(KeyCode::Char('d')))
);
}
#[test]
fn default_editor_deletion_includes_modified_backspace_delete_aliases() {
let runtime = RuntimeKeymap::defaults();
assert!(
runtime
.editor
.delete_backward
.contains(&key_hint::shift(KeyCode::Backspace))
);
assert!(
runtime
.editor
.delete_forward
.contains(&key_hint::shift(KeyCode::Delete))
);
assert!(
runtime
.editor
.delete_backward_word
.contains(&key_hint::ctrl(KeyCode::Backspace))
);
assert!(
runtime
.editor
.delete_backward_word
.contains(&KeyBinding::new(
KeyCode::Backspace,
KeyModifiers::CONTROL | KeyModifiers::SHIFT
))
);
assert!(
runtime
.editor
.delete_forward_word
.contains(&key_hint::ctrl(KeyCode::Delete))
);
assert!(
runtime
.editor
.delete_forward_word
.contains(&KeyBinding::new(
KeyCode::Delete,
KeyModifiers::CONTROL | KeyModifiers::SHIFT
))
);
}
#[test]
fn default_composer_toggle_shortcuts_includes_shift_question_mark() {
let runtime = RuntimeKeymap::defaults();
assert!(
runtime
.composer
.toggle_shortcuts
.contains(&key_hint::shift(KeyCode::Char('?')))
);
}
#[test]
fn default_approval_open_fullscreen_includes_ctrl_shift_a() {
let runtime = RuntimeKeymap::defaults();
assert!(runtime.approval.open_fullscreen.contains(&KeyBinding::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)));
}
#[test]
fn primary_binding_returns_first_or_none() {
let bindings = vec![
key_hint::ctrl(KeyCode::Char('a')),
key_hint::shift(KeyCode::Char('b')),
];
assert_eq!(
primary_binding(&bindings),
Some(key_hint::ctrl(KeyCode::Char('a')))
);
assert_eq!(primary_binding(&[]), None);
}
#[test]
fn defaults_pass_conflict_validation() {
RuntimeKeymap::defaults()
.validate_conflicts()
.expect("default keymap should be conflict free");
}
}