mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
## What changed Replace the `/presets` recall picker with a responsive menu above the composer. Press `0`–`9` to run a saved preset or create one in an empty slot; use `n`, `s`, `e`, and `d` to create, start from current settings, edit, and delete presets. Add `tui.keymap.chat.open_presets` with an implicit `leader p` shortcut that yields to existing bindings and explicit configuration. Show the action in `/keymap` and add a shortcut tooltip. Preserve the composer draft, respect popup ownership, and consume held-key repeats after menu actions. ## Testing Add snapshots for empty, populated, and narrow preset menus, plus coverage for draft preservation, cancellation, held-key handling, transcript search precedence, and default shortcut conflicts. GitOrigin-RevId: f438c7015dbd416c90cdeb479d2a15d9d6d24954
455 lines
13 KiB
Rust
455 lines
13 KiB
Rust
//! Shared inventory of resolved runtime bindings.
|
|
//!
|
|
//! `/keymap` lookup and chord-prefix conflict detection must inspect the same
|
|
//! actions. Declaring those actions once prevents a new binding from appearing
|
|
//! in the picker while being silently omitted from chord validation.
|
|
|
|
use super::RuntimeKeymap;
|
|
use crate::key_hint::KeyBinding;
|
|
use codex_config::types::KeybindingsSpec;
|
|
use codex_config::types::TuiKeymap;
|
|
use std::sync::Arc;
|
|
|
|
/// Runtime context in which a keymap action is active.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub(crate) enum KeymapContext {
|
|
Global,
|
|
Chat,
|
|
/// Voice-only chat action, inactive in pagers and other overlays.
|
|
Voice,
|
|
Composer,
|
|
Editor,
|
|
VimNormal,
|
|
VimOperator,
|
|
VimSearch,
|
|
VimTextObject,
|
|
Pager,
|
|
List,
|
|
Agents,
|
|
Approval,
|
|
}
|
|
|
|
impl KeymapContext {
|
|
pub(crate) const fn config_name(self) -> &'static str {
|
|
match self {
|
|
Self::Global => "global",
|
|
Self::Chat | Self::Voice => "chat",
|
|
Self::Composer => "composer",
|
|
Self::Editor => "editor",
|
|
Self::VimNormal => "vim_normal",
|
|
Self::VimOperator => "vim_operator",
|
|
Self::VimSearch => "vim_search",
|
|
Self::VimTextObject => "vim_text_object",
|
|
Self::Pager => "pager",
|
|
Self::List => "list",
|
|
Self::Agents => "agents",
|
|
Self::Approval => "approval",
|
|
}
|
|
}
|
|
|
|
pub(crate) const fn allows_plain_chord_prefix(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::VimNormal | Self::VimOperator | Self::VimSearch | Self::VimTextObject
|
|
)
|
|
}
|
|
|
|
pub(crate) fn overlaps(self, other: Self) -> bool {
|
|
if self == other {
|
|
return true;
|
|
}
|
|
|
|
matches!(
|
|
(self, other),
|
|
(Self::VimSearch, Self::VimNormal | Self::VimOperator)
|
|
| (Self::VimNormal | Self::VimOperator, Self::VimSearch)
|
|
| (Self::Voice, Self::Pager)
|
|
| (Self::Pager, Self::Voice)
|
|
| (Self::List, Self::Approval)
|
|
| (Self::Approval, Self::List)
|
|
| (Self::List, Self::Agents)
|
|
| (Self::Agents, Self::List)
|
|
| (Self::Chat, Self::List)
|
|
| (Self::List, Self::Chat)
|
|
) || self.is_shared_main() && other.is_main_editor()
|
|
|| other.is_shared_main() && self.is_main_editor()
|
|
|| self.is_shared_main() && other.is_shared_main()
|
|
}
|
|
|
|
const fn is_shared_main(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Global | Self::Chat | Self::Voice | Self::Composer
|
|
)
|
|
}
|
|
|
|
const fn is_main_editor(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Editor
|
|
| Self::VimNormal
|
|
| Self::VimOperator
|
|
| Self::VimSearch
|
|
| Self::VimTextObject
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Stable identity for one configurable action.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub(crate) struct KeymapActionId {
|
|
pub(crate) context: KeymapContext,
|
|
pub(crate) action: &'static str,
|
|
}
|
|
|
|
impl KeymapActionId {
|
|
/// Whether two actions can be active together, including list-specific exceptions.
|
|
pub(super) fn overlaps(self, other: Self) -> bool {
|
|
use KeymapContext::Chat;
|
|
use KeymapContext::Global;
|
|
use KeymapContext::List;
|
|
|
|
match (self.context, other.context) {
|
|
// Presets cannot open while a list owns input.
|
|
(Chat, List) => self.action != "open_presets",
|
|
(List, Chat) => other.action != "open_presets",
|
|
|
|
// Activity focus and Find remain available during list navigation.
|
|
(Global, List) => matches!(self.action, "focus_activity" | "find_transcript"),
|
|
(List, Global) => matches!(other.action, "focus_activity" | "find_transcript"),
|
|
|
|
_ => self.context.overlaps(other.context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn config_path(self) -> String {
|
|
format!("tui.keymap.{}.{}", self.context.config_name(), self.action)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(super) struct RuntimeActionBinding<'a> {
|
|
pub(super) id: KeymapActionId,
|
|
pub(super) bindings: &'a [KeyBinding],
|
|
}
|
|
|
|
macro_rules! runtime_group_mut {
|
|
($runtime_keymap:expr, editor) => {
|
|
Arc::make_mut(&mut $runtime_keymap.editor)
|
|
};
|
|
($runtime_keymap:expr, $group:ident) => {
|
|
&mut $runtime_keymap.$group
|
|
};
|
|
}
|
|
|
|
macro_rules! configured_binding_slot {
|
|
($keymap:ident, $group:ident, $action:ident, runtime_only) => {
|
|
None
|
|
};
|
|
($keymap:ident, $group:ident, $action:ident) => {
|
|
Some(&$keymap.$group.$action)
|
|
};
|
|
}
|
|
|
|
macro_rules! define_runtime_action_bindings {
|
|
($($context:literal => $context_id:ident, $group:ident, $config_group:ident [$($action:ident $(=> $runtime_only:ident)?),+ $(,)?]),+ $(,)?) => {
|
|
/// Resolve a config context/action pair to its runtime identity.
|
|
pub(crate) fn keymap_action_id(
|
|
context: &str,
|
|
action: &str,
|
|
) -> Option<KeymapActionId> {
|
|
match (context, action) {
|
|
$(
|
|
$(
|
|
($context, stringify!($action)) => Some(KeymapActionId {
|
|
context: KeymapContext::$context_id,
|
|
action: stringify!($action),
|
|
}),
|
|
)+
|
|
)+
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Return the configured slot for one runtime action, including global fallbacks.
|
|
pub(crate) fn configured_binding_for_action(
|
|
keymap: &TuiKeymap,
|
|
action: KeymapActionId,
|
|
) -> Option<&Option<KeybindingsSpec>> {
|
|
match (action.context.config_name(), action.action) {
|
|
("composer", "submit") if keymap.composer.submit.is_none() => {
|
|
Some(&keymap.global.submit)
|
|
}
|
|
("composer", "queue") if keymap.composer.queue.is_none() => {
|
|
Some(&keymap.global.queue)
|
|
}
|
|
("composer", "toggle_shortcuts") if keymap.composer.toggle_shortcuts.is_none() => {
|
|
Some(&keymap.global.toggle_shortcuts)
|
|
}
|
|
$(
|
|
$(
|
|
($context, stringify!($action)) => {
|
|
configured_binding_slot!(keymap, $config_group, $action $(, $runtime_only)?)
|
|
}
|
|
)+
|
|
)+
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Iterate over every stable runtime action identity.
|
|
pub(crate) fn keymap_action_ids() -> impl Iterator<Item = KeymapActionId> {
|
|
[
|
|
$(
|
|
$(
|
|
KeymapActionId {
|
|
context: KeymapContext::$context_id,
|
|
action: stringify!($action),
|
|
},
|
|
)+
|
|
)+
|
|
]
|
|
.into_iter()
|
|
}
|
|
|
|
/// Return resolved bindings for one stable config context and action.
|
|
pub(crate) fn bindings_for_action<'a>(
|
|
runtime_keymap: &'a RuntimeKeymap,
|
|
context: &str,
|
|
action: &str,
|
|
) -> Option<&'a [KeyBinding]> {
|
|
match (context, action) {
|
|
("global", "leader") => Some(runtime_keymap.chords.leader.as_slice()),
|
|
$(
|
|
$(
|
|
($context, stringify!($action)) => {
|
|
Some(super::user_bindings(
|
|
runtime_keymap.$group.$action.as_slice(),
|
|
))
|
|
}
|
|
)+
|
|
)+
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Append an internal dispatch binding to one resolved action.
|
|
pub(super) fn push_binding_for_action(
|
|
runtime_keymap: &mut RuntimeKeymap,
|
|
action: KeymapActionId,
|
|
binding: KeyBinding,
|
|
) -> bool {
|
|
match (action.context.config_name(), action.action) {
|
|
$(
|
|
$(
|
|
($context, stringify!($action)) => {
|
|
runtime_group_mut!(runtime_keymap, $group).$action.push(binding);
|
|
true
|
|
}
|
|
)+
|
|
)+
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Iterate over the same runtime bindings exposed to the keymap editor.
|
|
pub(super) fn runtime_action_bindings(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
) -> impl Iterator<Item = RuntimeActionBinding<'_>> {
|
|
[
|
|
$(
|
|
$(
|
|
RuntimeActionBinding {
|
|
id: KeymapActionId {
|
|
context: KeymapContext::$context_id,
|
|
action: stringify!($action),
|
|
},
|
|
bindings: super::user_bindings(
|
|
runtime_keymap.$group.$action.as_slice(),
|
|
),
|
|
},
|
|
)+
|
|
)+
|
|
]
|
|
.into_iter()
|
|
}
|
|
};
|
|
}
|
|
|
|
define_runtime_action_bindings! {
|
|
"global" => Global, app, global [
|
|
open_agents,
|
|
open_transcript,
|
|
find_transcript,
|
|
focus_activity,
|
|
open_warnings => runtime_only,
|
|
open_external_editor,
|
|
copy,
|
|
clear_terminal,
|
|
toggle_vim_mode,
|
|
toggle_fast_mode,
|
|
toggle_raw_output,
|
|
toggle_side_conversation,
|
|
],
|
|
"chat" => Chat, chat, chat [
|
|
open_presets,
|
|
toggle_voice,
|
|
interrupt_turn,
|
|
decrease_reasoning_effort,
|
|
increase_reasoning_effort,
|
|
previous_permission_mode,
|
|
next_permission_mode,
|
|
edit_queued_message,
|
|
prompt_stack_back,
|
|
skip_question,
|
|
],
|
|
"chat" => Voice, chat, chat [toggle_voice_mute],
|
|
"composer" => Composer, composer, composer [
|
|
submit,
|
|
queue,
|
|
toggle_shortcuts,
|
|
history_search_previous,
|
|
history_search_next,
|
|
],
|
|
"editor" => Editor, editor, editor [
|
|
insert_newline,
|
|
move_left,
|
|
move_right,
|
|
move_up,
|
|
move_down,
|
|
move_word_left,
|
|
move_word_right,
|
|
move_line_start,
|
|
move_line_end,
|
|
delete_backward,
|
|
delete_forward,
|
|
delete_backward_word,
|
|
delete_forward_word,
|
|
kill_line_start,
|
|
kill_whole_line,
|
|
kill_line_end,
|
|
yank,
|
|
],
|
|
"vim_normal" => VimNormal, vim_normal, vim_normal [
|
|
enter_insert,
|
|
append_after_cursor,
|
|
append_line_end,
|
|
insert_line_start,
|
|
open_line_below,
|
|
open_line_above,
|
|
enter_replace_mode,
|
|
move_left,
|
|
move_right,
|
|
move_up,
|
|
move_down,
|
|
move_word_forward,
|
|
move_word_backward,
|
|
move_word_end,
|
|
move_line_start,
|
|
move_line_end,
|
|
find_forward,
|
|
find_backward,
|
|
till_forward,
|
|
till_backward,
|
|
jump_top,
|
|
jump_bottom,
|
|
delete_char,
|
|
replace_char,
|
|
repeat_last_change,
|
|
substitute_char,
|
|
delete_to_line_end,
|
|
change_to_line_end,
|
|
yank_line,
|
|
paste_after,
|
|
start_delete_operator,
|
|
start_yank_operator,
|
|
start_change_operator,
|
|
undo,
|
|
redo,
|
|
cancel_operator,
|
|
],
|
|
"vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous],
|
|
"vim_operator" => VimOperator, vim_operator, vim_operator [
|
|
delete_line,
|
|
yank_line,
|
|
motion_left,
|
|
motion_right,
|
|
motion_up,
|
|
motion_down,
|
|
motion_word_forward,
|
|
motion_word_backward,
|
|
motion_word_end,
|
|
motion_line_start,
|
|
motion_line_end,
|
|
motion_find_forward,
|
|
motion_find_backward,
|
|
motion_till_forward,
|
|
motion_till_backward,
|
|
motion_jump_top,
|
|
motion_jump_bottom,
|
|
select_inner_text_object,
|
|
select_around_text_object,
|
|
cancel,
|
|
],
|
|
"vim_text_object" => VimTextObject, vim_text_object, vim_text_object [
|
|
word,
|
|
big_word,
|
|
parentheses,
|
|
brackets,
|
|
braces,
|
|
double_quote,
|
|
single_quote,
|
|
backtick,
|
|
cancel,
|
|
],
|
|
"pager" => Pager, pager, pager [
|
|
scroll_up,
|
|
scroll_down,
|
|
page_up,
|
|
page_down,
|
|
half_page_up,
|
|
half_page_down,
|
|
jump_top,
|
|
jump_bottom,
|
|
close,
|
|
close_transcript,
|
|
find,
|
|
],
|
|
"list" => List, list, list [
|
|
move_up,
|
|
move_down,
|
|
move_left,
|
|
move_right,
|
|
page_up,
|
|
page_down,
|
|
jump_top,
|
|
jump_bottom,
|
|
accept,
|
|
cancel,
|
|
],
|
|
"agents" => Agents, agents, agents [
|
|
resume,
|
|
search,
|
|
new_task,
|
|
new_worktree,
|
|
fork,
|
|
rename,
|
|
stop,
|
|
archive,
|
|
delete,
|
|
hide,
|
|
toggle_pin,
|
|
toggle_grouping,
|
|
],
|
|
"approval" => Approval, approval, approval [
|
|
open_fullscreen,
|
|
open_thread,
|
|
approve,
|
|
approve_for_session,
|
|
approve_for_prefix,
|
|
deny,
|
|
decline,
|
|
cancel,
|
|
],
|
|
}
|