diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index afc599e09e..5bd8933ecc 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -26,20 +26,21 @@ use std::collections::BTreeMap; /// Highest function key supported by portable TUI keymap configuration. pub const MAX_FUNCTION_KEY: u8 = 24; -/// Normalized string representation of a single key event (for example `ctrl-a`). +/// Maximum number of key events in one configurable TUI binding. +const MAX_KEY_CHORD_STROKES: usize = 2; + +/// Normalized representation of one key event or a two-stroke key chord. /// /// The parser accepts a small alias set (for example `escape` -> `esc`, -/// `pageup` -> `page-up`) and stores the canonical form. -/// -/// This deliberately represents one terminal key event, not a sequence of -/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding -/// multi-step chords would require a separate runtime state machine. +/// `pageup` -> `page-up`) and stores each stroke in its canonical form. Chord +/// strokes are separated by one space, for example `ctrl-x ctrl-s`. Arrays of +/// bindings remain alternatives; their entries do not form a chord together. #[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(transparent)] pub struct KeybindingSpec(#[schemars(with = "String")] pub String); impl KeybindingSpec { - /// Returns the canonical key-spec string (for example `ctrl-a`). + /// Returns the canonical key-spec string (for example `ctrl-x ctrl-s`). pub fn as_str(&self) -> &str { self.0.as_str() } @@ -60,8 +61,8 @@ impl<'de> Deserialize<'de> for KeybindingSpec { /// /// This accepts either: /// -/// 1. A single key spec string (`"ctrl-a"`). -/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`). +/// 1. A single key or chord string (`"ctrl-a"` or `"ctrl-x ctrl-s"`). +/// 2. A list of alternative bindings (`["ctrl-a", "ctrl-x ctrl-s"]`). /// /// An empty list explicitly unbinds the action in that scope. Because an /// explicit empty list is still a configured value, runtime resolution must not @@ -436,6 +437,26 @@ pub struct TuiKeymap { /// this function when accepting user-authored key specs, or otherwise equivalent /// keys can fail to compare equal in tests, UI hints, and duplicate detection. fn normalize_keybinding_spec(raw: &str) -> Result { + let strokes = raw.split_whitespace().collect::>(); + if strokes.is_empty() { + return normalize_keybinding_stroke(raw); + } + if strokes.len() > MAX_KEY_CHORD_STROKES { + return Err(format!( + "invalid keybinding `{raw}`: key chords may contain at most \ +{MAX_KEY_CHORD_STROKES} strokes (for example `ctrl-x ctrl-s`)." + )); + } + + strokes + .into_iter() + .map(normalize_keybinding_stroke) + .collect::, _>>() + .map(|normalized| normalized.join(" ")) +} + +/// Normalize the key and modifiers for one stroke in a key binding. +fn normalize_keybinding_stroke(raw: &str) -> Result { let lower = raw.trim().to_ascii_lowercase(); if lower.is_empty() { return Err( @@ -687,3 +708,7 @@ mod tests { assert!(normalize_keybinding_spec("f25").is_err()); } } + +#[cfg(test)] +#[path = "tui_keymap_chord_tests.rs"] +mod chord_tests; diff --git a/codex-rs/config/src/tui_keymap_chord_tests.rs b/codex-rs/config/src/tui_keymap_chord_tests.rs new file mode 100644 index 0000000000..87f34588d9 --- /dev/null +++ b/codex-rs/config/src/tui_keymap_chord_tests.rs @@ -0,0 +1,76 @@ +use super::KeybindingSpec; +use super::KeybindingsSpec; +use super::TuiKeymap; +use super::normalize_keybinding_spec; +use pretty_assertions::assert_eq; + +#[test] +fn normalizes_each_stroke_in_a_key_chord() { + assert_eq!( + normalize_keybinding_spec(" CONTROL-X Option-Return "), + Ok("ctrl-x alt-enter".to_string()) + ); +} + +#[test] +fn deserializes_a_two_stroke_global_key_chord() { + let actual = toml::from_str::( + r#" + [global] + open_transcript = "ctrl-x ctrl-t" + "#, + ) + .expect("two-stroke key chords should deserialize"); + + let expected = TuiKeymap { + global: super::TuiGlobalKeymap { + open_transcript: Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x ctrl-t".to_string(), + ))), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(actual, expected); +} + +#[test] +fn keeps_single_keys_and_chords_as_alternative_bindings() { + let actual = toml::from_str::( + r#" + [global] + open_transcript = ["ctrl-t", "ctrl-x ctrl-t"] + "#, + ) + .expect("single keys and key chords should coexist"); + + let expected = TuiKeymap { + global: super::TuiGlobalKeymap { + open_transcript: Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-t".to_string()), + KeybindingSpec("ctrl-x ctrl-t".to_string()), + ])), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(actual, expected); +} + +#[test] +fn rejects_chords_longer_than_two_strokes() { + let error = normalize_keybinding_spec("ctrl-x ctrl-s ctrl-t") + .expect_err("key chords should have a bounded length"); + + assert!(error.contains("at most 2 strokes"), "{error}"); +} + +#[test] +fn rejects_an_invalid_second_chord_stroke() { + let error = normalize_keybinding_spec("ctrl-x ctrl-unknown") + .expect_err("every key chord stroke should be valid"); + + assert!(error.contains("unknown key"), "{error}"); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2b3506c398..e9df85d783 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1427,7 +1427,7 @@ "type": "array" } ], - "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." + "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key or chord string (`\"ctrl-a\"` or `\"ctrl-x ctrl-s\"`). 2. A list of alternative bindings (`[\"ctrl-a\", \"ctrl-x ctrl-s\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." }, "LegacyAppPathString": { "type": "string" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5c1a6f9a73..aabbc4b855 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -49,6 +49,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::UpdateAvailableHistoryCell; use crate::hooks_rpc::HookTrustUpdate; use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeyChordMatcher; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; @@ -537,6 +538,7 @@ pub(crate) struct App { pub(crate) enhanced_keys_supported: bool, pub(crate) keymap: RuntimeKeymap, + pub(crate) key_chord_matcher: KeyChordMatcher, /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, @@ -1048,6 +1050,7 @@ See the Codex keymap documentation for supported actions and examples." file_search, enhanced_keys_supported, keymap: runtime_keymap, + key_chord_matcher: KeyChordMatcher::default(), transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), @@ -1292,9 +1295,19 @@ See the Codex keymap documentation for supported actions and examples." ) -> Result { let screen_size = tui.screen_size_for_event(&event)?; if !matches!(&event, TuiEvent::Key(_) | TuiEvent::Paste(_)) { + self.expire_pending_key_chord(); self.handle_draw_pre_render(tui, screen_size)?; } + let event = if let TuiEvent::Key(key_event) = event { + let Some(key_event) = self.route_key_chord_event(tui, key_event) else { + return Ok(AppRunControl::Continue); + }; + TuiEvent::Key(key_event) + } else { + event + }; + if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index fa6bf71862..2e7d60e26b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2349,9 +2349,15 @@ impl App { context, action, intent, + capture_mode, } => { - self.chat_widget - .open_keymap_capture(context, action, intent, &self.keymap); + self.chat_widget.open_keymap_capture( + context, + action, + intent, + capture_mode, + &self.keymap, + ); } AppEvent::OpenKeymapDebug => { self.chat_widget.open_keymap_debug(&self.keymap); @@ -2424,6 +2430,7 @@ impl App { .await { Ok(()) => { + self.cancel_pending_key_chord(); self.config.tui_keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget @@ -2475,6 +2482,7 @@ impl App { .await { Ok(()) => { + self.cancel_pending_key_chord(); self.config.tui_keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index b81b479f3d..617c2e53f0 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -7,6 +7,83 @@ use super::*; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; impl App { + pub(super) fn route_key_chord_event( + &mut self, + tui: &mut tui::Tui, + key_event: KeyEvent, + ) -> Option { + let contexts = self.active_keymap_contexts(); + let was_pending = self.key_chord_matcher.is_pending(); + match self.key_chord_matcher.advance( + key_event, + &self.keymap.chords, + contexts, + tokio::time::Instant::now(), + ) { + crate::keymap::KeyChordMatch::PassThrough => { + if was_pending && !self.key_chord_matcher.is_pending() { + self.chat_widget.set_footer_hint_override(/*items*/ None); + } + Some(key_event) + } + crate::keymap::KeyChordMatch::Pending(prefix) => { + if self.backtrack.primed { + self.reset_backtrack_state(); + } + self.chat_widget.set_footer_hint_override(Some(vec![ + ( + format!("{} …", prefix.display_label()), + "waiting for next key".to_string(), + ), + ("esc".to_string(), "cancel".to_string()), + ])); + tui.frame_requester() + .schedule_frame_in(crate::keymap::KEY_CHORD_TIMEOUT); + None + } + crate::keymap::KeyChordMatch::Completed(dispatch_event) => { + self.chat_widget.set_footer_hint_override(/*items*/ None); + Some(dispatch_event) + } + crate::keymap::KeyChordMatch::Cancelled => { + self.chat_widget.set_footer_hint_override(/*items*/ None); + None + } + crate::keymap::KeyChordMatch::Ignored => None, + } + } + + pub(super) fn expire_pending_key_chord(&mut self) { + let contexts = self.active_keymap_contexts(); + if self + .key_chord_matcher + .expire(contexts, tokio::time::Instant::now()) + { + self.chat_widget.set_footer_hint_override(/*items*/ None); + } + } + + pub(super) fn cancel_pending_key_chord(&mut self) { + if self.key_chord_matcher.cancel() { + self.chat_widget.set_footer_hint_override(/*items*/ None); + } + } + + fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.overlay.is_some() { + return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Pager); + } + + let contexts = self.chat_widget.keymap_contexts(); + if self.chat_widget.no_modal_or_popup_active() { + contexts + .with(crate::keymap::KeymapContext::Global) + .with(crate::keymap::KeymapContext::Chat) + } else { + contexts + } + } + pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { let editor_cmd = match external_editor::resolve_editor_command() { Ok(cmd) => cmd, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 477e1a3811..93cb683eb0 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -244,8 +244,10 @@ impl App { .side_threads .values() .any(|state| state.parent_thread_id == active_thread_id) - && let Some(binding) = - crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation) + && let Some(binding) = self.keymap.primary_hint( + crate::keymap::KeymapContext::Global, + "toggle_side_conversation", + ) { self.chat_widget .set_side_conversation_context_label(Some(format!( @@ -273,9 +275,10 @@ impl App { if let Some(parent_status) = parent_status { label_parts.push(parent_status.label(parent_is_main).to_string()); } - if let Some(binding) = - crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation) - { + if let Some(binding) = self.keymap.primary_hint( + crate::keymap::KeymapContext::Global, + "toggle_side_conversation", + ) { label_parts.push(format!("{} to switch", binding.display_label())); } label_parts.push("ctrl + c to close".to_string()); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 13d7702f5b..f52ef8c013 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -40,6 +40,7 @@ pub(super) async fn make_test_app() -> App { initial_history_replay_buffer: None, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), + key_chord_matcher: crate::keymap::KeyChordMatcher::default(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 1ef8c4bc2c..d6c6a6fafc 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2,6 +2,8 @@ #[path = "tests/advanced_reasoning_tests.rs"] mod advanced_reasoning_tests; +#[path = "tests/key_chords.rs"] +mod key_chords; mod model_catalog; mod plugin_catalog; mod rate_limits; @@ -4740,6 +4742,7 @@ async fn make_test_app() -> App { initial_history_replay_buffer: None, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), + key_chord_matcher: crate::keymap::KeyChordMatcher::default(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -4808,6 +4811,7 @@ async fn make_test_app_with_channels() -> ( initial_history_replay_buffer: None, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), + key_chord_matcher: crate::keymap::KeyChordMatcher::default(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs new file mode 100644 index 0000000000..e5fcba5838 --- /dev/null +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -0,0 +1,217 @@ +use super::App; +use super::AppServerSession; +use super::Result; +use super::RuntimeKeymap; +use super::TuiEvent; +use super::make_test_app; +use super::start_config_write_test_app_server; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::keymap::KeymapContext; +use crate::test_support::test_path_display; +use crate::tui::Tui; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +async fn chord_app() -> Result<(App, Tui, AppServerSession)> { + let mut app = make_test_app().await; + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x ctrl-t".to_string(), + ))); + config.chat.interrupt_turn = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x ctrl-u".to_string(), + ))); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-x j".to_string()))); + config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + + let app_server = start_config_write_test_app_server(&app).await?; + let tui = crate::tui::test_support::make_test_tui()?; + Ok((app, tui, app_server)) +} + +async fn press( + app: &mut App, + tui: &mut Tui, + app_server: &mut AppServerSession, + key: KeyEvent, +) -> Result<()> { + app.handle_tui_event(tui, app_server, TuiEvent::Key(key)) + .await?; + Ok(()) +} + +fn ctrl(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL) +} + +#[tokio::test] +async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + assert!(app.overlay.is_none()); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&test_path_display("/tmp/project"), "/tmp/project"), + @r" + › Ask Codex to do anything + + ctrl + x … waiting for next key esc cancel + " + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?; + assert!(!app.key_chord_matcher.is_pending()); + assert!(app.overlay.is_some()); + Ok(()) +} + +#[tokio::test] +async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + let wrong_second = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!( + app.route_key_chord_event(&mut tui, wrong_second), + Some(wrong_second) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.route_key_chord_event(&mut tui, escape), None); + assert!(!app.key_chord_matcher.is_pending()); + assert!(!app.backtrack.primed); + Ok(()) +} + +#[tokio::test] +async fn physical_dispatch_band_events_are_dropped() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let (code, modifiers) = app + .keymap + .app + .open_transcript + .last() + .expect("configured chord appends a dispatch token") + .parts(); + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(code, modifiers), + ) + .await?; + + assert!(app.overlay.is_none()); + Ok(()) +} + +#[tokio::test] +async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("list"), + items: ["First", "Second"] + .into_iter() + .map(|name| SelectionItem { + name: name.to_string(), + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..Default::default() + }); + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(0) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(0) + ); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('j').into(), + ) + .await?; + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(1) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?; + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + None + ); + + app.chat_widget + .handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "choice".to_string(), + header: "Pick one".to_string(), + question: "Choose an option.".to_string(), + is_other: false, + is_secret: false, + options: Some( + ["First", "Second"] + .into_iter() + .map(|label| ToolRequestUserInputOption { + label: label.to_string(), + description: label.to_string(), + }) + .collect(), + ), + }], + is_blocking: true, + auto_resolution_ms: None, + }); + let contexts = app.chat_widget.keymap_contexts(); + assert!(contexts.contains(KeymapContext::Chat)); + assert!(contexts.contains(KeymapContext::List)); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 1. First")); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('j').into(), + ) + .await?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 2. Second")); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?; + assert!(app.chat_widget.can_launch_external_editor()); + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a570ee5808..6037b7225b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -176,6 +176,13 @@ pub(crate) enum KeymapEditIntent { ReplaceOne { old_key: String }, } +/// Number of key strokes recorded by one `/keymap` capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeymapCaptureMode { + SingleKey, + Chord, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -1116,6 +1123,7 @@ pub(crate) enum AppEvent { context: String, action: String, intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, }, /// Open the keymap keypress inspector. diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index bbd7f355c3..fab39e2502 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -29,7 +29,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; -use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; @@ -669,23 +669,29 @@ impl AppLinkView { } fn hint_line(&self) -> Line<'static> { - Line::from(vec![ - "Use ".into(), - key_hint::plain(KeyCode::Tab).into(), - " / ".into(), - key_hint::plain(KeyCode::Up).into(), - " ".into(), - key_hint::plain(KeyCode::Down).into(), - " to move, ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to select, ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]) + let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()]; + if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) { + spans.extend([" / ".into(), move_up.into()]); + } + if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) { + spans.extend([" ".into(), move_down.into()]); + } + spans.push(" to move".into()); + if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) { + spans.extend([", ".into(), accept.into(), " to select".into()]); + } + if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) { + spans.extend([", ".into(), cancel.into(), " to close".into()]); + } + Line::from(spans) } } impl BottomPaneView for AppLinkView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -693,6 +699,9 @@ impl BottomPaneView for AppLinkView { } => { self.on_ctrl_c(); } + _ if self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) => { + self.on_ctrl_c(); + } KeyEvent { code: KeyCode::Up, .. } @@ -705,7 +714,13 @@ impl BottomPaneView for AppLinkView { modifiers: KeyModifiers::NONE, .. } => self.move_selection_prev(), - _ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(), + _ if matches!( + self.list_keymap.action_for(key_event), + Some(ListAction::MoveUp | ListAction::MoveLeft) + ) => + { + self.move_selection_prev() + } KeyEvent { code: KeyCode::Down, .. @@ -718,7 +733,16 @@ impl BottomPaneView for AppLinkView { modifiers: KeyModifiers::NONE, .. } => self.move_selection_next(), - _ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(), + _ if matches!( + self.list_keymap.action_for(key_event), + Some(ListAction::MoveDown | ListAction::MoveRight) + ) => + { + self.move_selection_next() + } + _ if self.list_keymap.action_for(key_event) == Some(ListAction::Accept) => { + self.activate_selected_action(); + } KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ccab67ef05..2f97b329f2 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -33,8 +33,8 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; use crate::keymap::ApprovalKeymap; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; -use crate::keymap::primary_binding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -291,7 +291,7 @@ impl ApprovalOverlay { .iter() .map(|opt| SelectionItem { name: opt.label.clone(), - display_shortcut: opt.shortcuts.first().copied(), + display_shortcut: approval_keymap.hint_for_bindings(&opt.shortcuts), dismiss_on_select: false, ..Default::default() }) @@ -563,6 +563,11 @@ impl ApprovalOverlay { } impl BottomPaneView for ApprovalOverlay { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval) + .with(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if self.try_handle_shortcut(&key_event) { return; @@ -619,14 +624,15 @@ fn approval_footer_hint( list_keymap: &ListKeymap, ) -> Line<'static> { let mut spans = accept_cancel_hint_line( - primary_binding(&list_keymap.accept), + list_keymap.primary_hint(ListAction::Accept), "to confirm", - primary_binding(&list_keymap.cancel), + list_keymap.primary_hint(ListAction::Cancel), "to cancel", ) .spans; if request.thread_label().is_some() - && let Some(open_thread) = primary_binding(&approval_keymap.open_thread) + && let Some(open_thread) = + approval_keymap.primary_hint("open_thread", &approval_keymap.open_thread) { if !spans.is_empty() { spans.push(" or ".into()); @@ -1106,11 +1112,15 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::keymap::RuntimeKeymap; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::ExecPolicyAmendment; use codex_app_server_protocol::NetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1242,6 +1252,58 @@ mod tests { }) } + #[test] + fn approval_shortcuts_display_chords_in_configured_order() { + let chord = crate::key_hint::ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::Char('y')), + }; + let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y'))); + let binding = |spec: &str| KeybindingSpec(spec.to_string()); + for (specs, expected_shortcut) in [ + (KeybindingsSpec::One(binding("ctrl-x y")), chord), + ( + KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]), + single, + ), + ( + KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]), + chord, + ), + ] { + let mut config = TuiKeymap::default(); + config.approval.approve = Some(specs); + let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap"); + let request = make_exec_request(); + let (_, params) = ApprovalOverlay::build_options( + &request, + build_header(&request), + &Features::with_defaults(), + &keymap.approval, + &keymap.list, + ); + let approval = params + .items + .iter() + .find(|item| item.name == "Yes, proceed") + .expect("approval selection"); + + assert_eq!(approval.display_shortcut, Some(expected_shortcut)); + if matches!( + expected_shortcut, + crate::key_hint::ShortcutHint::Chord { .. } + ) { + let dispatch_token = keymap.approval.approve.last().expect("chord token"); + assert_eq!( + keymap + .approval + .hint_for_bindings(std::slice::from_ref(dispatch_token)), + Some(expected_shortcut) + ); + } + } + } + #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 551bacd25a..e4d1b5f0a5 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,6 +1,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::McpServerElicitationFormRequest; +use crate::keymap::KeymapContextSet; use crate::render::renderable::Renderable; use codex_app_server_protocol::ToolRequestUserInputParams; use crossterm::event::KeyEvent; @@ -21,6 +22,11 @@ pub(crate) trait BottomPaneView: Renderable { /// scheduled after this call. fn handle_key_event(&mut self, _key_event: KeyEvent) {} + /// Return the keymap contexts whose handlers are active in this view. + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::default() + } + /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { false diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index da34da70be..af5ec97452 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -171,6 +171,7 @@ //! use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; use crate::key_hint::has_ctrl_or_alt; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::ui_consts::FOOTER_INDENT_COLS; @@ -248,9 +249,11 @@ use crate::bottom_pane::paste_burst::FlushResult; use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; -use crate::keymap::primary_binding; +use crate::keymap::user_bindings; use crate::onboarding::mark_underlined_hyperlink; use crate::render::Insets; use crate::render::RectExt; @@ -625,19 +628,24 @@ impl ChatComposer { status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, - external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), - show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + external_editor_key: default_keymap + .primary_hint(KeymapContext::Global, "open_external_editor"), + show_transcript_key: default_keymap + .primary_hint(KeymapContext::Global, "open_transcript"), insert_newline_key: footer_insert_newline_key( &default_keymap.editor.insert_newline, use_shift_enter_hint, - ), - queue_key: Some(key_hint::plain(KeyCode::Tab)), - toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), - history_search_key: primary_binding( - &default_keymap.composer.history_search_previous, - ), - reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort), - reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort), + ) + .map(ShortcutHint::from), + queue_key: default_keymap.primary_hint(KeymapContext::Composer, "queue"), + toggle_shortcuts_key: default_keymap + .primary_hint(KeymapContext::Composer, "toggle_shortcuts"), + history_search_key: default_keymap + .primary_hint(KeymapContext::Composer, "history_search_previous"), + reasoning_down_key: default_keymap + .primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"), + reasoning_up_key: default_keymap + .primary_hint(KeymapContext::Chat, "increase_reasoning_effort"), }, has_focus: has_input_focus, frame_requester: None, @@ -842,17 +850,39 @@ impl ChatComposer { self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); self.draft.textarea.set_keymap_bindings(keymap); - self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor); - self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript); - self.footer.insert_newline_key = footer_insert_newline_key( - &keymap.editor.insert_newline, - self.footer.use_shift_enter_hint, - ); - self.footer.queue_key = primary_binding(&keymap.composer.queue); - self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); - self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous); - self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); - self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); + self.footer.external_editor_key = + keymap.primary_hint(KeymapContext::Global, "open_external_editor"); + self.footer.show_transcript_key = + keymap.primary_hint(KeymapContext::Global, "open_transcript"); + self.footer.insert_newline_key = + match keymap.primary_hint(KeymapContext::Editor, "insert_newline") { + hint @ Some(ShortcutHint::Chord { .. }) => hint, + _ => footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ) + .map(ShortcutHint::from), + }; + self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue"); + self.footer.toggle_shortcuts_key = + keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts"); + self.footer.history_search_key = + keymap.primary_hint(KeymapContext::Composer, "history_search_previous"); + self.footer.reasoning_down_key = + keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"); + self.footer.reasoning_up_key = + keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"); + } + + /// Return the contexts whose handlers can consume the next composer key. + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + if self.history_search.is_some() { + return KeymapContextSet::new(KeymapContext::Composer); + } + if !self.draft.input_enabled || self.popups.active() { + return KeymapContextSet::default(); + } + KeymapContextSet::new(KeymapContext::Composer).with(self.draft.textarea.keymap_context()) } pub fn set_collaboration_mode_indicator( @@ -3679,7 +3709,7 @@ impl ChatComposer { queue: self.footer.queue_key, insert_newline: self.footer.insert_newline_key, external_editor: self.footer.external_editor_key, - edit_previous: Some(key_hint::plain(KeyCode::Esc)), + edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), show_transcript: self.footer.show_transcript_key, history_search: self.footer.history_search_key, reasoning_down: self.footer.reasoning_down_key, @@ -4247,6 +4277,7 @@ fn footer_insert_newline_key( bindings: &[KeyBinding], enhanced_keys_supported: bool, ) -> Option { + let bindings = user_bindings(bindings); let shift_enter = key_hint::shift(KeyCode::Enter); if enhanced_keys_supported && bindings.contains(&shift_enter) { return Some(shift_enter); @@ -4850,6 +4881,50 @@ mod tests { ) } + #[test] + fn shortcut_footer_displays_configured_chords() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + + let mut config = TuiKeymap::default(); + config.global.open_external_editor = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-g ctrl-g".to_string(), + ))); + config.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid composer chords"); + let (mut composer, _rx) = new_test_composer(); + composer.set_keymap_bindings(&keymap); + composer.footer.mode = FooterMode::ShortcutOverlay; + + let hints = composer.footer_props().key_hints; + assert_eq!( + hints.external_editor, + Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('g')), + completion: key_hint::ctrl(KeyCode::Char('g')), + }) + ); + assert_eq!( + hints.insert_newline, + Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::Enter), + }) + ); + + snapshot_composer_state( + "footer_mode_configured_key_chords", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_keymap_bindings(&keymap); + composer.footer.mode = FooterMode::ShortcutOverlay; + }, + ); + } + #[test] fn parent_owned_thread_allows_bare_navigation_commands() { for (command, expected) in [ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 113385ff72..3ad87a1462 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::footer::CollaborationModeIndicator; use crate::bottom_pane::footer::FooterMode; use crate::bottom_pane::footer::GoalStatusIndicator; use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; #[cfg(test)] use std::time::Duration; @@ -30,14 +31,14 @@ pub(super) struct FooterState { pub(super) status_line_enabled: bool, pub(super) side_conversation_context_label: Option, pub(super) active_agent_label: Option, - pub(super) external_editor_key: Option, - pub(super) show_transcript_key: Option, - pub(super) insert_newline_key: Option, - pub(super) queue_key: Option, - pub(super) toggle_shortcuts_key: Option, - pub(super) history_search_key: Option, - pub(super) reasoning_down_key: Option, - pub(super) reasoning_up_key: Option, + pub(super) external_editor_key: Option, + pub(super) show_transcript_key: Option, + pub(super) insert_newline_key: Option, + pub(super) queue_key: Option, + pub(super) toggle_shortcuts_key: Option, + pub(super) history_search_key: Option, + pub(super) reasoning_down_key: Option, + pub(super) reasoning_up_key: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index 6d57e58b6c..8813d0655d 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -14,6 +14,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; @@ -66,7 +67,7 @@ impl ExperimentalFeaturesView { complete: false, app_event_tx, header: Box::new(header), - footer_hint: experimental_popup_hint_line(), + footer_hint: experimental_popup_hint_line(&keymap), keymap, }; view.initialize_selection(); @@ -164,6 +165,10 @@ impl ExperimentalFeaturesView { } impl BottomPaneView for ExperimentalFeaturesView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), @@ -282,12 +287,18 @@ impl Renderable for ExperimentalFeaturesView { } } -fn experimental_popup_hint_line() -> Line<'static> { - Line::from(vec![ +fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> { + let mut spans = vec![ "Press ".into(), key_hint::plain(KeyCode::Char(' ')).into(), - " to select or ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to save for next conversation".into(), - ]) + " to select".into(), + ]; + if let Some(accept) = keymap.primary_hint(ListAction::Accept) { + spans.extend([ + " or ".into(), + accept.into(), + " to save for next conversation".into(), + ]); + } + Line::from(spans) } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 4e1689edad..1bb3c7a4a2 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -43,6 +43,7 @@ //! `FooterProps` mapping. use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; use crate::render::line_utils::prefix_lines; use crate::status::format_tokens_compact; use crate::ui_consts::FOOTER_INDENT_COLS; @@ -106,30 +107,30 @@ const FOOTER_CONTEXT_GAP_COLS: u16 = 1; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct FooterKeyHints { - pub(crate) toggle_shortcuts: Option, - pub(crate) queue: Option, - pub(crate) insert_newline: Option, - pub(crate) external_editor: Option, - pub(crate) edit_previous: Option, - pub(crate) show_transcript: Option, - pub(crate) history_search: Option, - pub(crate) reasoning_down: Option, - pub(crate) reasoning_up: Option, + pub(crate) toggle_shortcuts: Option, + pub(crate) queue: Option, + pub(crate) insert_newline: Option, + pub(crate) external_editor: Option, + pub(crate) edit_previous: Option, + pub(crate) show_transcript: Option, + pub(crate) history_search: Option, + pub(crate) reasoning_down: Option, + pub(crate) reasoning_up: Option, } impl FooterKeyHints { #[cfg(test)] pub(crate) fn default_bindings() -> Self { Self { - toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))), - queue: Some(key_hint::plain(KeyCode::Tab)), - insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))), - external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))), - edit_previous: Some(key_hint::plain(KeyCode::Esc)), - show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))), - history_search: Some(key_hint::ctrl(KeyCode::Char('r'))), - reasoning_down: Some(key_hint::alt(KeyCode::Char(','))), - reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))), + toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?')).into()), + queue: Some(key_hint::plain(KeyCode::Tab).into()), + insert_newline: Some(key_hint::ctrl(KeyCode::Char('j')).into()), + external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()), + edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), + show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()), + history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()), + reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()), + reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()), } } } @@ -1087,7 +1088,9 @@ impl ShortcutDescriptor { | ShortcutId::FilePaths | ShortcutId::PasteImage | ShortcutId::Quit - | ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key), + | ShortcutId::ChangeMode => self + .binding_for(state) + .map(|binding| ShortcutHint::Single(binding.key)), }?; let mut line = Line::from(vec![self.prefix.into(), key.into()]); match self.id { @@ -1576,7 +1579,7 @@ mod tests { status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints { - insert_newline: Some(key_hint::shift(KeyCode::Enter)), + insert_newline: Some(key_hint::shift(KeyCode::Enter).into()), ..FooterKeyHints::default_bindings() }, active_agent_label: None, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 53e5d62665..1e3646dbfd 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -30,6 +30,7 @@ use crate::hooks_rpc::HookTrustUpdate; use crate::hooks_rpc::hook_needs_review; use crate::key_hint; use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; @@ -511,35 +512,40 @@ impl HooksBrowserView { width: area.width.saturating_sub(2), height: area.height, }; + let accept = self.keymap.primary_hint(ListAction::Accept); + let cancel = self + .keymap + .primary_hint(ListAction::Cancel) + .unwrap_or_else(|| key_hint::plain(KeyCode::Esc).into()); let footer = match self.page { - HooksBrowserPage::Events if self.review_needed_total_count() > 0 => Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Char('t')).into(), - " to trust all; ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to review hooks; ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]), - HooksBrowserPage::Events => Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to view hooks; ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]), + HooksBrowserPage::Events if self.review_needed_total_count() > 0 => { + let mut spans = vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char('t')).into(), + " to trust all; ".into(), + ]; + if let Some(accept) = accept { + spans.extend([accept.into(), " to review hooks; ".into()]); + } + spans.extend([cancel.into(), " to close".into()]); + Line::from(spans) + } + HooksBrowserPage::Events => { + let mut spans = vec!["Press ".into()]; + if let Some(accept) = accept { + spans.extend([accept.into(), " to view hooks; ".into()]); + } + spans.extend([cancel.into(), " to close".into()]); + Line::from(spans) + } HooksBrowserPage::Handlers(event_name) => { let selected_hook = self.selected_hook(event_name); if selected_hook.is_none() { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to go back".into(), - ]) + Line::from(vec!["Press ".into(), cancel.into(), " to go back".into()]) } else if selected_hook.is_some_and(|hook| hook.is_managed) { Line::from(vec![ "Managed hooks are always on; press ".into(), - key_hint::plain(KeyCode::Esc).into(), + cancel.into(), " to go back".into(), ]) } else if selected_hook.is_some_and(hook_needs_review) { @@ -547,19 +553,17 @@ impl HooksBrowserView { "Press ".into(), key_hint::plain(KeyCode::Char('t')).into(), " to trust; ".into(), - key_hint::plain(KeyCode::Esc).into(), + cancel.into(), " to go back".into(), ]) } else { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Char(' ')).into(), - " or ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to toggle; ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to go back".into(), - ]) + let mut spans = + vec!["Press ".into(), key_hint::plain(KeyCode::Char(' ')).into()]; + if let Some(accept) = accept { + spans.extend([" or ".into(), accept.into()]); + } + spans.extend([" to toggle; ".into(), cancel.into(), " to go back".into()]); + Line::from(spans) } } }; @@ -568,6 +572,10 @@ impl HooksBrowserView { } impl BottomPaneView for HooksBrowserView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), @@ -1002,8 +1010,10 @@ mod tests { #[test] fn renders_event_browser() { - let view = view(); + let mut view = view(); assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); + view.keymap.accept.clear(); + assert!(!render_lines(&view, /*width*/ 112).contains("enter to view hooks")); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7ef6f281f0..04c6514f62 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -16,8 +16,8 @@ use super::selection_popup_common::render_menu_surface; use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::clipboard_paste::normalize_pasted_search_query; -use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; use crate::key_hint::is_plain_text_key_event; use crate::keymap::ListKeymap; use crate::render::renderable::ColumnRenderable; @@ -135,7 +135,7 @@ pub(crate) struct SelectionItem { pub name_prefix_spans: Vec>, pub toggle: Option, pub toggle_placeholder: Option<&'static str>, - pub display_shortcut: Option, + pub display_shortcut: Option, pub description: Option, pub selected_description: Option, pub is_current: bool, @@ -950,6 +950,10 @@ impl ListSelectionView { } impl BottomPaneView for ListSelectionView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { // Searchable lists reserve printable characters for query input. This // keeps vim-style plain j/k/h/l useful in non-search lists without diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 82dbebac17..b43c8b5383 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -49,8 +49,11 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; -use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; use crate::text_formatting::format_json_compact; use crate::text_formatting::truncate_text; @@ -713,6 +716,7 @@ pub(crate) struct McpServerElicitationOverlay { done: bool, validation_error: Option, list_keymap: ListKeymap, + composer_submit_hint: Option, } impl McpServerElicitationOverlay { @@ -730,7 +734,7 @@ impl McpServerElicitationOverlay { has_input_focus, enhanced_keys_supported, disable_paste_burst, - crate::keymap::RuntimeKeymap::defaults().list, + RuntimeKeymap::defaults(), ) } @@ -740,8 +744,9 @@ impl McpServerElicitationOverlay { has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, - list_keymap: ListKeymap, + keymap: RuntimeKeymap, ) -> Self { + let composer_submit_hint = keymap.primary_hint(KeymapContext::Composer, "submit"); let mut composer = ChatComposer::new_with_config( has_input_focus, app_event_tx.clone(), @@ -750,6 +755,7 @@ impl McpServerElicitationOverlay { disable_paste_burst, ChatComposerConfig::plain_text(), ); + composer.set_keymap_bindings(&keymap); composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { app_event_tx, @@ -760,7 +766,8 @@ impl McpServerElicitationOverlay { current_idx: 0, done: false, validation_error: None, - list_keymap, + list_keymap: keymap.list, + composer_submit_hint, }; overlay.reset_for_request(); overlay.restore_current_draft(); @@ -984,20 +991,21 @@ impl McpServerElicitationOverlay { fn footer_tips(&self) -> Vec { let mut tips = Vec::new(); let is_last_field = self.current_index().saturating_add(1) >= self.field_count(); - if self.current_field_is_select() { - if self.field_count() == 1 { - tips.push(FooterTip::highlighted("enter to submit")); - } else if is_last_field { - tips.push(FooterTip::highlighted("enter to submit all")); - } else { - tips.push(FooterTip::new("enter to submit answer")); - } - } else if self.field_count() == 1 { - tips.push(FooterTip::highlighted("enter to submit")); - } else if is_last_field { - tips.push(FooterTip::highlighted("enter to submit all")); + let submit_hint = if self.current_field_is_select() { + self.list_keymap.primary_hint(ListAction::Accept) } else { - tips.push(FooterTip::new("enter to submit answer")); + self.composer_submit_hint + }; + if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) { + if self.field_count() == 1 { + tips.push(FooterTip::highlighted(format!("{submit_hint} to submit"))); + } else if is_last_field { + tips.push(FooterTip::highlighted(format!( + "{submit_hint} to submit all" + ))); + } else { + tips.push(FooterTip::new(format!("{submit_hint} to submit answer"))); + } } if self.field_count() > 1 { if self.current_field_is_select() { @@ -1505,6 +1513,14 @@ impl Renderable for McpServerElicitationOverlay { } impl BottomPaneView for McpServerElicitationOverlay { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.current_field_is_select() { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } else { + self.composer.keymap_contexts() + } + } + fn prefer_esc_to_handle_key_event(&self) -> bool { true } @@ -1514,7 +1530,10 @@ impl BottomPaneView for McpServerElicitationOverlay { return; } - if matches!(key_event.code, KeyCode::Esc) { + if matches!(key_event.code, KeyCode::Esc) + || self.current_field_is_select() + && self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) + { self.dispatch_cancel(); self.done = true; return; @@ -1561,7 +1580,7 @@ impl BottomPaneView for McpServerElicitationOverlay { return; } _ if self.current_field_is_select() - && self.list_keymap.move_left.is_pressed(key_event) => + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) => { self.move_field(/*next*/ false); return; @@ -1580,7 +1599,7 @@ impl BottomPaneView for McpServerElicitationOverlay { return; } _ if self.current_field_is_select() - && self.list_keymap.move_right.is_pressed(key_event) => + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) => { self.move_field(/*next*/ true); return; @@ -1591,28 +1610,28 @@ impl BottomPaneView for McpServerElicitationOverlay { if self.current_field_is_select() { self.validation_error = None; let options_len = self.options_len(); - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { + match (self.list_keymap.action_for(key_event), key_event.code) { + (Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => { if let Some(answer) = self.current_answer_mut() { answer.selection.move_up_wrap(options_len); answer.answer_committed = false; } } - KeyCode::Down | KeyCode::Char('j') => { + (Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => { if let Some(answer) = self.current_answer_mut() { answer.selection.move_down_wrap(options_len); answer.answer_committed = false; } } - KeyCode::Backspace | KeyCode::Delete => self.clear_selection(), - KeyCode::Char(' ') => self.select_current_option(/*committed*/ true), - KeyCode::Enter => { + (_, KeyCode::Backspace | KeyCode::Delete) => self.clear_selection(), + (_, KeyCode::Char(' ')) => self.select_current_option(/*committed*/ true), + (Some(ListAction::Accept), _) | (_, KeyCode::Enter) => { if self.selected_option_index().is_some() { self.select_current_option(/*committed*/ true); } self.go_next_or_submit(); } - KeyCode::Char(ch) => { + (_, KeyCode::Char(ch)) => { if let Some(option_idx) = self.option_index_for_digit(ch) { if let Some(answer) = self.current_answer_mut() { answer.selection.selected_idx = Some(option_idx); @@ -1738,6 +1757,9 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::render::renderable::Renderable; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; use pretty_assertions::assert_eq; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; @@ -2234,6 +2256,73 @@ mod tests { assert_eq!(overlay.current_idx, 1); overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); assert_eq!(overlay.current_idx, 0); + overlay.list_keymap.accept.clear(); + assert!( + overlay + .footer_tips() + .iter() + .all(|tip| !tip.text.contains("submit")) + ); + } + + #[test] + fn text_fields_inherit_composer_chord_context_and_submit_binding() { + let (tx, _rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Enter a name", + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + } + }, + "required": ["name"], + }), + /*meta*/ None, + ), + ) + .expect("supported text field"); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid MCP composer chord"); + let mut overlay = McpServerElicitationOverlay::new_with_keymap( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ true, keymap, + ); + + let snapshot = render_snapshot( + &overlay, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10, + ), + ) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!( + "mcp_server_elicitation_text_configured_key_chords", + snapshot + ); + assert!( + overlay + .keymap_contexts() + .contains(crate::keymap::KeymapContext::Composer) + ); + overlay.composer_submit_hint = None; + assert!( + overlay + .footer_tips() + .iter() + .all(|tip| !tip.text.contains("submit")) + ); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 793596b468..0dd58b75c9 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -12,9 +12,10 @@ use ratatui::widgets::Widget; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::key_hint; use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; @@ -290,14 +291,18 @@ impl MemoriesSettingsView { fn footer_hint(&self) -> Line<'static> { if self.reset_confirmation.is_some() { - standard_popup_hint_line() + standard_popup_hint_line_for_keymap(&self.keymap) } else { - memories_settings_hint_line() + memories_settings_hint_line(&self.keymap) } } } impl BottomPaneView for MemoriesSettingsView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), @@ -459,12 +464,14 @@ impl Renderable for MemoriesSettingsView { } } -fn memories_settings_hint_line() -> Line<'static> { - Line::from(vec![ +fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> { + let mut spans = vec![ "Press ".into(), key_hint::plain(KeyCode::Char(' ')).into(), - " to toggle; ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to save or select".into(), - ]) + " to toggle".into(), + ]; + if let Some(accept) = keymap.primary_hint(ListAction::Accept) { + spans.extend(["; ".into(), accept.into(), " to save or select".into()]); + } + Line::from(spans) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 693a3300b1..e6455e2eb3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -27,8 +27,9 @@ use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; use crate::keymap::RuntimeKeymap; -use crate::keymap::primary_binding; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -398,7 +399,7 @@ impl BottomPane { pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.keymap = keymap.clone(); self.composer.set_keymap_bindings(keymap); - let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn); + let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"); self.pending_input_preview .set_interrupt_binding(interrupt_binding); if let Some(status) = self.status.as_mut() { @@ -485,7 +486,10 @@ impl BottomPane { /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. - pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option) { + pub(crate) fn set_queued_message_edit_binding( + &mut self, + binding: Option, + ) { self.pending_input_preview.set_edit_binding(binding); self.request_redraw(); } @@ -693,6 +697,15 @@ impl BottomPane { } } + /// Return the contexts whose ordinary handlers can consume the next key. + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + if let Some(view) = self.view_stack.last() { + view.keymap_contexts() + } else { + self.composer.keymap_contexts() + } + } + /// Handles a Ctrl+C press within the bottom pane. /// /// An active modal view is given the first chance to consume the key (typically to dismiss @@ -1047,7 +1060,10 @@ impl BottomPane { } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(/*visible*/ true); - status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); + status.set_interrupt_binding( + self.keymap + .primary_hint(KeymapContext::Chat, "interrupt_turn"), + ); } self.sync_status_inline_message(); self.request_redraw(); @@ -1077,7 +1093,10 @@ impl BottomPane { self.animations_enabled, )); if let Some(status) = self.status.as_mut() { - status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); + status.set_interrupt_binding( + self.keymap + .primary_hint(KeymapContext::Chat, "interrupt_turn"), + ); } self.sync_status_inline_message(); self.request_redraw(); @@ -1553,7 +1572,7 @@ impl BottomPane { self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, - self.keymap.list.clone(), + self.keymap.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index 619ff31b5d..7f89e332eb 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -63,9 +63,9 @@ use crate::bottom_pane::selection_popup_common::render_rows_single_line; use crate::key_hint; use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::keymap::RuntimeKeymap; -use crate::keymap::primary_binding; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt; @@ -515,6 +515,10 @@ impl MultiSelectPicker { } impl BottomPaneView for MultiSelectPicker { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn is_complete(&self) -> bool { self.complete } @@ -828,8 +832,8 @@ impl MultiSelectPickerBuilder { ]; if self.ordering_enabled && let (Some(move_left), Some(move_right)) = ( - primary_binding(&self.keymap.move_left), - primary_binding(&self.keymap.move_right), + self.keymap.primary_hint(ListAction::MoveLeft), + self.keymap.primary_hint(ListAction::MoveRight), ) { spans.push("; ".into()); @@ -838,12 +842,12 @@ impl MultiSelectPickerBuilder { spans.push(move_right.into()); spans.push(" to move".into()); } - if let Some(accept) = primary_binding(&self.keymap.accept) { + if let Some(accept) = self.keymap.primary_hint(ListAction::Accept) { spans.push("; ".into()); spans.push(accept.into()); spans.push(" to confirm and close".into()); } - if let Some(cancel) = primary_binding(&self.keymap.cancel) { + if let Some(cancel) = self.keymap.primary_hint(ListAction::Cancel) { spans.push("; ".into()); spans.push(cancel.into()); spans.push(" to close".into()); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 96cc1bdfd8..e4a49a55a8 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -26,9 +26,9 @@ pub(crate) struct PendingInputPreview { pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. - edit_binding: Option, + edit_binding: Option, /// Key combination rendered for immediately interrupting and sending steers. - interrupt_binding: Option, + interrupt_binding: Option, } const PREVIEW_LINE_LIMIT: usize = 3; @@ -39,19 +39,19 @@ impl PendingInputPreview { pending_steers: Vec::new(), rejected_steers: Vec::new(), queued_messages: Vec::new(), - edit_binding: Some(key_hint::alt(KeyCode::Up)), - interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), + edit_binding: Some(key_hint::alt(KeyCode::Up).into()), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()), } } /// Replace the keybinding shown in the hint line at the bottom of the /// queued-messages list. The caller is responsible for also wiring the /// corresponding key event handler. - pub(crate) fn set_edit_binding(&mut self, binding: Option) { + pub(crate) fn set_edit_binding(&mut self, binding: Option) { self.edit_binding = binding; } - pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { self.interrupt_binding = binding; } @@ -216,7 +216,7 @@ mod tests { fn render_one_message_with_shift_left_binding() { let mut queue = PendingInputPreview::new(); queue.queued_messages.push("Hello, world!".to_string()); - queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left))); + queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left).into())); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -337,7 +337,7 @@ mod tests { fn render_one_pending_steer_with_remapped_interrupt_binding() { let mut queue = PendingInputPreview::new(); queue.pending_steers.push("Please continue.".to_string()); - queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into())); let width = 48; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index d85b9c6385..e2c7c8a56b 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -3,9 +3,9 @@ use ratatui::text::Line; use crate::key_hint; -use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; -use crate::keymap::primary_binding; use crossterm::event::KeyCode; /// Maximum number of rows any popup should attempt to display. @@ -25,17 +25,17 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> { pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { accept_cancel_hint_line( - primary_binding(&list_keymap.accept), + list_keymap.primary_hint(ListAction::Accept), "to confirm", - primary_binding(&list_keymap.cancel), + list_keymap.primary_hint(ListAction::Cancel), "to go back", ) } pub(crate) fn accept_cancel_hint_line( - accept: Option, + accept: Option, accept_label: &'static str, - cancel: Option, + cancel: Option, cancel_label: &'static str, ) -> Line<'static> { match (accept, cancel) { diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 87fb8c0708..494c7cbb03 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -33,6 +33,9 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; @@ -170,7 +173,9 @@ pub(crate) struct RequestUserInputOverlay { request_started_at: Instant, auto_resolution_snoozed: bool, composer_submit_keys: Vec, + composer_submit_hint: Option, interrupt_turn_keys: Vec, + interrupt_turn_hint: Option, list_keymap: ListKeymap, } @@ -228,7 +233,9 @@ impl RequestUserInputOverlay { request_started_at: Instant::now(), auto_resolution_snoozed: false, composer_submit_keys: keymap.composer.submit.clone(), + composer_submit_hint: keymap.primary_hint(KeymapContext::Composer, "submit"), interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), + interrupt_turn_hint: keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"), list_keymap: keymap.list, }; overlay.reset_for_request(); @@ -594,11 +601,11 @@ impl RequestUserInputOverlay { let question_count = self.question_count(); let is_last_question = self.current_index().saturating_add(1) >= question_count; let submit_key = if self.focus_is_notes() || !self.has_options() { - self.composer_submit_keys - .first() - .map(KeyBinding::display_label) + self.composer_submit_hint.map(ShortcutHint::display_label) } else { - Some("enter".to_string()) + self.list_keymap + .primary_hint(ListAction::Accept) + .map(ShortcutHint::display_label) }; if let Some(submit_key) = submit_key { let submit_tip = if question_count == 1 { @@ -617,10 +624,10 @@ impl RequestUserInputOverlay { tips.push(FooterTip::new("ctrl + p / ctrl + n change question")); } } - if let Some(interrupt_key) = self.interrupt_turn_keys.first() + if let Some(interrupt_key) = self.interrupt_turn_hint && !(self.has_options() && notes_visible - && *interrupt_key == crate::key_hint::plain(KeyCode::Esc)) + && interrupt_key == ShortcutHint::Single(crate::key_hint::plain(KeyCode::Esc))) { tips.push(FooterTip::new(format!( "{} to interrupt", @@ -1179,6 +1186,19 @@ impl RequestUserInputOverlay { } impl BottomPaneView for RequestUserInputOverlay { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.confirm_unanswered_active() { + return crate::keymap::KeymapContextSet::default(); + } + if matches!(self.focus, Focus::Options) { + return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + .with(crate::keymap::KeymapContext::Chat); + } + self.composer + .keymap_contexts() + .with(crate::keymap::KeymapContext::Chat) + } + fn prefer_esc_to_handle_key_event(&self) -> bool { true } @@ -1280,7 +1300,7 @@ impl BottomPaneView for RequestUserInputOverlay { } _ if self.has_options() && matches!(self.focus, Focus::Options) - && self.list_keymap.move_left.is_pressed(key_event) => + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) => { self.move_question(/*next*/ false); return; @@ -1300,7 +1320,7 @@ impl BottomPaneView for RequestUserInputOverlay { } _ if self.has_options() && matches!(self.focus, Focus::Options) - && self.list_keymap.move_right.is_pressed(key_event) => + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) => { self.move_question(/*next*/ true); return; @@ -1312,8 +1332,8 @@ impl BottomPaneView for RequestUserInputOverlay { Focus::Options => { let options_len = self.options_len(); // Keep selection synchronized as the user moves. - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { + match (self.list_keymap.action_for(key_event), key_event.code) { + (Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => { let moved = if let Some(answer) = self.current_answer_mut() { answer.options_state.move_up_wrap(options_len); answer.answer_committed = false; @@ -1325,7 +1345,7 @@ impl BottomPaneView for RequestUserInputOverlay { self.sync_composer_placeholder(); } } - KeyCode::Down | KeyCode::Char('j') => { + (Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => { let moved = if let Some(answer) = self.current_answer_mut() { answer.options_state.move_down_wrap(options_len); answer.answer_committed = false; @@ -1337,24 +1357,24 @@ impl BottomPaneView for RequestUserInputOverlay { self.sync_composer_placeholder(); } } - KeyCode::Char(' ') => { + (_, KeyCode::Char(' ')) => { self.select_current_option(/*committed*/ true); } - KeyCode::Backspace | KeyCode::Delete => { + (_, KeyCode::Backspace | KeyCode::Delete) => { self.clear_selection(); } - KeyCode::Tab if self.selected_option_index().is_some() => { + (_, KeyCode::Tab) if self.selected_option_index().is_some() => { self.focus = Focus::Notes; self.ensure_selected_for_notes(); } - KeyCode::Enter => { + (Some(ListAction::Accept), _) | (_, KeyCode::Enter) => { let has_selection = self.selected_option_index().is_some(); if has_selection { self.select_current_option(/*committed*/ true); } self.go_next_or_submit(); } - KeyCode::Char(ch) => { + (_, KeyCode::Char(ch)) => { if let Some(option_idx) = self.option_index_for_digit(ch) { if let Some(answer) = self.current_answer_mut() { answer.options_state.selected_idx = Some(option_idx); @@ -1516,6 +1536,9 @@ mod tests { use crate::app_event::AppEvent; use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::render::renderable::Renderable; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -2514,6 +2537,49 @@ mod tests { ); } + #[test] + fn freeform_footer_displays_configured_chords_without_internal_dispatch_keys() { + for (specs, expected_tips) in [ + ( + KeybindingsSpec::One(KeybindingSpec("ctrl-x enter".to_string())), + vec!["ctrl + x enter to submit answer", "esc to interrupt"], + ), + ( + KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("ctrl-x enter".to_string()), + ]), + vec!["ctrl + enter to submit answer", "esc to interrupt"], + ), + ( + KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-x enter".to_string()), + KeybindingSpec("ctrl-enter".to_string()), + ]), + vec!["ctrl + x enter to submit answer", "esc to interrupt"], + ), + ] { + let (tx, _rx) = test_sender(); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(specs); + let keymap = RuntimeKeymap::from_config(&config).expect("valid submit keymap"); + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let tips = overlay.footer_tips(); + + assert_eq!( + tips.iter().map(|tip| tip.text.as_str()).collect::>(), + expected_tips + ); + } + } + #[test] fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() { let (tx, mut rx) = test_sender(); @@ -3656,6 +3722,44 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_chord_only_submit_snapshot() { + let (tx, _rx) = test_sender(); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid submit chord"); + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 10, + ); + let snapshot = render_snapshot(&overlay, area) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(snapshot, @r" + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + ctrl + x enter to submit answer | esc to interrupt + "); + } + #[test] fn request_user_input_freeform_remapped_submit_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index e1841286cf..9481006400 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -8,7 +8,7 @@ use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::Widget; -use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; use crate::line_truncation::line_width; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; @@ -32,7 +32,7 @@ use super::selection_row_layout::wrap_stacked_row; pub(crate) struct GenericDisplayRow { pub name: String, pub name_prefix_spans: Vec>, - pub display_shortcut: Option, + pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) pub description: Option, // optional grey text after the name pub category_tag: Option, // optional right-side category label diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs index b35088314c..e3146db820 100644 --- a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -15,9 +15,10 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; -use crate::keymap::primary_binding; use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; @@ -232,6 +233,10 @@ impl SkillsToggleView { } impl BottomPaneView for SkillsToggleView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { // Printable characters always feed search. Movement aliases such as // plain j/k only apply through non-text events or modified bindings. @@ -388,8 +393,10 @@ impl Renderable for SkillsToggleView { fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> { let space = key_hint::plain(KeyCode::Char(' ')); - let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space); - let cancel = primary_binding(&keymap.cancel); + let accept = keymap + .primary_hint(ListAction::Accept) + .filter(|binding| *binding != ShortcutHint::Single(space)); + let cancel = keymap.primary_hint(ListAction::Cancel); match (accept, cancel) { (Some(accept), Some(cancel)) => Line::from(vec![ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap new file mode 100644 index 0000000000..efad837915 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands ! for shell commands " +" ctrl + x enter for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap new file mode 100644 index 0000000000..4a1435d838 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: snapshot +--- + + Field 1/1 (1 required unanswered) + Enter a name + + Name + + › Type your answer + + ctrl + x enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 63ce4237db..32d6daa279 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -371,6 +371,10 @@ impl StatusLineSetupView { } impl BottomPaneView for StatusLineSetupView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { self.picker.handle_key_event(key_event); } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index ed65662f86..98bf0126d8 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -13,6 +13,7 @@ use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; use crate::keymap::EditorKeymap; +use crate::keymap::KeymapContext; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::VimOperatorKeymap; @@ -280,6 +281,18 @@ impl TextArea { !matches!(self.vim_pending, VimPending::None) } + /// Return the keymap context that owns the next editing key. + pub(crate) fn keymap_context(&self) -> KeymapContext { + if !self.vim_enabled || self.vim_mode == VimMode::Insert { + return KeymapContext::Editor; + } + match self.vim_pending { + VimPending::None => KeymapContext::VimNormal, + VimPending::Operator(_) => KeymapContext::VimOperator, + VimPending::TextObject { .. } => KeymapContext::VimTextObject, + } + } + /// Enter Vim insert mode if modal editing is enabled. /// /// Calling this while Vim is disabled is a no-op, which lets parent diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index 1a3993c1cd..bf4919c94e 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -345,6 +345,10 @@ impl TerminalTitleSetupView { } impl BottomPaneView for TerminalTitleSetupView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { self.picker.handle_key_event(key_event); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d56c63d2f5..a161321da8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -240,14 +240,24 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB } fn queued_message_edit_hint_binding( - bindings: &[KeyBinding], + keymap: &RuntimeKeymap, terminal_info: TerminalInfo, -) -> Option { +) -> Option { + let configured = keymap.primary_hint(crate::keymap::KeymapContext::Chat, "edit_queued_message"); + if matches!( + configured, + Some(crate::key_hint::ShortcutHint::Chord { .. }) + ) { + return configured; + } + let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info); - bindings + keymap + .chat + .edit_queued_message .contains(&terminal_binding) - .then_some(terminal_binding) - .or_else(|| bindings.first().copied()) + .then_some(crate::key_hint::ShortcutHint::Single(terminal_binding)) + .or(configured) } fn normalize_thread_name(name: &str) -> Option { @@ -681,7 +691,7 @@ pub(crate) struct ChatWidget { /// Keybinding to show for popping the most-recently queued message back /// into the composer. This may differ from the first configured binding /// when the default set includes a terminal-specific fallback. - queued_message_edit_hint_binding: Option, + queued_message_edit_hint_binding: Option, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 302a80ab75..99430339bc 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -82,7 +82,7 @@ impl ChatWidget { .map(|keymap| keymap.chat.clone()) .unwrap_or_else(|| default_keymap.chat.clone()); let queued_message_edit_hint_binding = queued_message_edit_hint_binding( - &chat_keymap.edit_queued_message, + runtime_keymap.as_ref().unwrap_or(&default_keymap), current_terminal_info, ); let pet_http_client = codex_http_client::RouteAwareClientPool::new( diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 58cde58ed3..b6d97c59be 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -3,6 +3,10 @@ use super::*; impl ChatWidget { + pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + self.bottom_pane.keymap_contexts() + } + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { if self.bottom_pane.has_active_view() && !matches!( diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 8e5f01f04a..25f802cb8f 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -16,6 +16,7 @@ use codex_terminal_detection::terminal_info; use super::ChatWidget; use super::queued_message_edit_hint_binding; +use crate::app_event::KeymapCaptureMode; use crate::app_event::KeymapEditIntent; use crate::keymap::RuntimeKeymap; use crate::keymap_setup; @@ -73,12 +74,14 @@ impl ChatWidget { context: String, action: String, intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, runtime_keymap: &RuntimeKeymap, ) { let view = keymap_setup::build_keymap_capture_view( context, action, intent, + capture_mode, runtime_keymap, self.app_event_tx.clone(), ); @@ -169,10 +172,8 @@ impl ChatWidget { self.config.tui_keymap = keymap_config; self.copy_last_response_binding = runtime_keymap.app.copy.clone(); self.chat_keymap = runtime_keymap.chat.clone(); - self.queued_message_edit_hint_binding = queued_message_edit_hint_binding( - &self.chat_keymap.edit_queued_message, - terminal_info(), - ); + self.queued_message_edit_hint_binding = + queued_message_edit_hint_binding(runtime_keymap, terminal_info()); self.bottom_pane .set_queued_message_edit_binding(self.queued_message_edit_hint_binding); self.bottom_pane.set_keymap_bindings(runtime_keymap); diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index ba3e10082b..d8cd725a32 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -468,14 +468,14 @@ impl ChatWidget { let items = vec![ SelectionItem { name: "Yes".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y')).into()), actions: send_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "No".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), + display_shortcut: Some(key_hint::plain(KeyCode::Char('n')).into()), is_default: true, dismiss_on_select: true, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap new file mode 100644 index 0000000000..e3cfbd477a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Experimental features + Toggle experimental features. Changes are saved to config.toml. + +› [x] Shell tool Allow the model to run shell commands. + + Press space to select or ctrl + x enter to save for next conversation diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 84125d932c..49fa1dd5e5 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1363,7 +1363,7 @@ async fn restore_thread_input_state_applies_running_state_policy() { async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)]; - chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up)); + chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up).into()); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); @@ -1462,6 +1462,42 @@ async fn shift_left_edits_most_recent_queued_message_in_tmux() { .await; } +#[test] +fn queued_message_edit_hint_displays_configured_chords() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + + let terminal_info = || TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }; + let mut config = TuiKeymap::default(); + config.chat.edit_queued_message = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x up".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid queued edit chord"); + + assert_eq!( + queued_message_edit_hint_binding(&keymap, terminal_info()), + Some(crate::key_hint::ShortcutHint::Chord { + prefix: crate::key_hint::ctrl(KeyCode::Char('x')), + completion: crate::key_hint::plain(KeyCode::Up), + }) + ); + + let default_keymap = RuntimeKeymap::defaults(); + assert_eq!( + queued_message_edit_hint_binding(&default_keymap, terminal_info()), + Some(crate::key_hint::ShortcutHint::Single(crate::key_hint::alt( + KeyCode::Up, + ))) + ); +} + #[test] fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 5bba3a8345..e4e75a54c9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1173,7 +1173,7 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.queued_message_edit_hint_binding = - Some(queued_message_edit_binding_for_terminal(terminal_info)); + Some(queued_message_edit_binding_for_terminal(terminal_info).into()); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 61050f3ff3..43c42e8ae4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2896,6 +2896,26 @@ async fn experimental_features_popup_snapshot() { let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("experimental_features_popup", popup); + + let mut config = codex_config::types::TuiKeymap::default(); + config.list.accept = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("ctrl-x enter".to_string()), + )); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config) + .expect("valid experimental-feature chord"); + let view = ExperimentalFeaturesView::new( + vec![ExperimentalFeatureItem { + feature: Feature::ShellTool, + name: "Shell tool".to_string(), + description: "Allow the model to run shell commands.".to_string(), + enabled: true, + }], + chat.app_event_tx.clone(), + keymap.list, + ); + chat.bottom_pane.show_view(Box::new(view)); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("experimental_features_popup_configured_key_chords", popup); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index cb0ebdebef..ff3b3fb5df 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1780,6 +1780,7 @@ async fn keymap_capture_can_capture_current_copy_shortcut() { "composer".to_string(), "submit".to_string(), crate::app_event::KeymapEditIntent::ReplaceAll, + crate::app_event::KeymapCaptureMode::SingleKey, &runtime_keymap, ); @@ -1814,6 +1815,7 @@ async fn slash_keymap_capture_can_capture_app_shortcuts() { "global".to_string(), "open_transcript".to_string(), crate::app_event::KeymapEditIntent::ReplaceAll, + crate::app_event::KeymapCaptureMode::SingleKey, &runtime_keymap, ); diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 372ccb61a7..c0da2b8afb 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -45,6 +45,37 @@ pub(crate) struct KeyBinding { modifiers: KeyModifiers, } +/// A user-visible shortcut made from one key or a two-key chord. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum ShortcutHint { + Single(KeyBinding), + Chord { + prefix: KeyBinding, + completion: KeyBinding, + }, +} + +impl ShortcutHint { + pub(crate) fn display_label(self) -> String { + match self { + Self::Single(binding) => binding.display_label(), + Self::Chord { prefix, completion } => { + format!("{} {}", prefix.display_label(), completion.display_label()) + } + } + } + + pub(crate) fn is_press(self, event: KeyEvent) -> bool { + matches!(self, Self::Single(binding) if binding.is_press(event)) + } +} + +impl From for ShortcutHint { + fn from(binding: KeyBinding) -> Self { + Self::Single(binding) + } +} + impl KeyBinding { pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self { Self { key, modifiers } @@ -193,6 +224,12 @@ impl From<&KeyBinding> for Span<'static> { } } +impl From for Span<'static> { + fn from(hint: ShortcutHint) -> Self { + Span::styled(hint.display_label(), key_hint_style()) + } +} + fn key_hint_style() -> Style { Style::default().dim() } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 7c06550822..2d82dbbb54 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -20,13 +20,30 @@ use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; use codex_config::types::KeybindingsSpec; use codex_config::types::MAX_FUNCTION_KEY; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use serde::Serialize; use std::collections::HashMap; +use std::sync::Arc; + +mod bindings; +mod chords; + +pub(crate) use bindings::KeymapContext; +pub(crate) use bindings::bindings_for_action; +pub(crate) use bindings::keymap_action_id; +use bindings::runtime_action_bindings; +pub(crate) use chords::KEY_CHORD_TIMEOUT; +pub(crate) use chords::KeyChordMatch; +pub(crate) use chords::KeyChordMatcher; +pub(crate) use chords::KeymapContextSet; +pub(crate) use chords::RuntimeChordKeymap; /// Runtime keymap used by TUI input handlers. /// @@ -43,6 +60,7 @@ use std::collections::HashMap; #[derive(Clone, Debug)] pub(crate) struct RuntimeKeymap { pub(crate) app: AppKeymap, + pub(crate) chords: Arc, pub(crate) chat: ChatKeymap, pub(crate) composer: ComposerKeymap, pub(crate) editor: EditorKeymap, @@ -224,6 +242,50 @@ pub(crate) struct PagerKeymap { pub(crate) jump_bottom: Vec, pub(crate) close: Vec, pub(crate) close_transcript: Vec, + chord_hints: Arc, +} + +impl PagerKeymap { + pub(crate) fn primary_hint( + &self, + action: &'static str, + bindings: &[KeyBinding], + ) -> Option { + let action = keymap_action_id(KeymapContext::Pager.config_name(), action)?; + self.chord_hints.primary_hint(action, bindings) + } +} + +/// Semantic navigation and confirmation actions shared by list-like views. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ListAction { + MoveUp, + MoveDown, + MoveLeft, + MoveRight, + PageUp, + PageDown, + JumpTop, + JumpBottom, + Accept, + Cancel, +} + +impl ListAction { + fn config_name(self) -> &'static str { + match self { + Self::MoveUp => "move_up", + Self::MoveDown => "move_down", + Self::MoveLeft => "move_left", + Self::MoveRight => "move_right", + Self::PageUp => "page_up", + Self::PageDown => "page_down", + Self::JumpTop => "jump_top", + Self::JumpBottom => "jump_bottom", + Self::Accept => "accept", + Self::Cancel => "cancel", + } + } } /// Generic list picker keybindings shared across popup list views. @@ -247,6 +309,47 @@ pub(crate) struct ListKeymap { pub(crate) jump_bottom: Vec, pub(crate) accept: Vec, pub(crate) cancel: Vec, + chord_hints: Arc, +} + +impl ListKeymap { + pub(crate) fn bindings_for(&self, action: ListAction) -> &[KeyBinding] { + match action { + ListAction::MoveUp => &self.move_up, + ListAction::MoveDown => &self.move_down, + ListAction::MoveLeft => &self.move_left, + ListAction::MoveRight => &self.move_right, + ListAction::PageUp => &self.page_up, + ListAction::PageDown => &self.page_down, + ListAction::JumpTop => &self.jump_top, + ListAction::JumpBottom => &self.jump_bottom, + ListAction::Accept => &self.accept, + ListAction::Cancel => &self.cancel, + } + } + + pub(crate) fn action_for(&self, event: KeyEvent) -> Option { + [ + ListAction::MoveUp, + ListAction::MoveDown, + ListAction::MoveLeft, + ListAction::MoveRight, + ListAction::PageUp, + ListAction::PageDown, + ListAction::JumpTop, + ListAction::JumpBottom, + ListAction::Accept, + ListAction::Cancel, + ] + .into_iter() + .find(|action| self.bindings_for(*action).is_pressed(event)) + } + + pub(crate) fn primary_hint(&self, action: ListAction) -> Option { + let action_id = keymap_action_id(KeymapContext::List.config_name(), action.config_name())?; + self.chord_hints + .primary_hint(action_id, self.bindings_for(action)) + } } /// Approval modal keybindings. @@ -263,6 +366,38 @@ pub(crate) struct ApprovalKeymap { pub(crate) deny: Vec, pub(crate) decline: Vec, pub(crate) cancel: Vec, + chord_hints: Arc, +} + +impl ApprovalKeymap { + pub(crate) fn primary_hint( + &self, + action: &'static str, + bindings: &[KeyBinding], + ) -> Option { + let action = keymap_action_id(KeymapContext::Approval.config_name(), action)?; + self.chord_hints.primary_hint(action, bindings) + } + + pub(crate) fn hint_for_bindings(&self, bindings: &[KeyBinding]) -> Option { + [ + ("open_fullscreen", self.open_fullscreen.as_slice()), + ("open_thread", self.open_thread.as_slice()), + ("approve", self.approve.as_slice()), + ("approve_for_session", self.approve_for_session.as_slice()), + ("approve_for_prefix", self.approve_for_prefix.as_slice()), + ("deny", self.deny.as_slice()), + ("decline", self.decline.as_slice()), + ("cancel", self.cancel.as_slice()), + ] + .into_iter() + .find_map(|(action, configured)| { + (!bindings.is_empty() && bindings.iter().all(|binding| configured.contains(binding))) + .then(|| self.primary_hint(action, configured)) + .flatten() + }) + .or_else(|| primary_binding(bindings).map(ShortcutHint::from)) + } } /// Returns the first binding, used as the primary UI hint for an action. @@ -270,7 +405,19 @@ pub(crate) struct ApprovalKeymap { /// Rendering code should prefer this for concise hints while preserving all /// bindings for actual input matching. pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option { - bindings.first().copied() + user_bindings(bindings).first().copied() +} + +/// Remove the trailing internal dispatch token, when present. +pub(crate) fn user_bindings(bindings: &[KeyBinding]) -> &[KeyBinding] { + if bindings + .last() + .is_some_and(|binding| chords::is_dispatch_token(*binding)) + { + &bindings[..bindings.len() - 1] + } else { + bindings + } } /// Resolve one context-local action binding from config. @@ -387,6 +534,7 @@ impl RuntimeKeymap { /// dispatch, or conflict guarantees from this resolver no longer hold. pub(crate) fn from_config(keymap: &TuiKeymap) -> Result { let defaults = Self::built_in_defaults(); + let chords = Arc::new(RuntimeChordKeymap::from_config(keymap)?); let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none() && ["ctrl-/", "ctrl-7"].into_iter().any(|alias| { configured_main_surface_alias_is_used(keymap, alias) @@ -783,6 +931,7 @@ impl RuntimeKeymap { jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom), close: resolve_local!(keymap, defaults, pager, close), close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), + chord_hints: Arc::clone(&chords), }; let approval = ApprovalKeymap { @@ -794,6 +943,7 @@ impl RuntimeKeymap { deny: resolve_local!(keymap, defaults, approval, deny), decline: resolve_local!(keymap, defaults, approval, decline), cancel: resolve_local!(keymap, defaults, approval, cancel), + chord_hints: Arc::clone(&chords), }; let list_move_up = resolve_local!(keymap, defaults, list, move_up); @@ -903,10 +1053,12 @@ impl RuntimeKeymap { )?, accept: list_accept, cancel: list_cancel, + chord_hints: Arc::clone(&chords), }; - let resolved = Self { + let mut resolved = Self { app, + chords, chat, composer, editor, @@ -919,9 +1071,22 @@ impl RuntimeKeymap { }; resolved.validate_conflicts()?; + chords::validate_chord_conflicts(&resolved)?; + chords::install_dispatch_bindings(&mut resolved)?; Ok(resolved) } + /// Resolve the visible primary shortcut from configured declaration order. + pub(crate) fn primary_hint( + &self, + context: KeymapContext, + action: &'static str, + ) -> Option { + let action_id = keymap_action_id(context.config_name(), action)?; + let bindings = bindings_for_action(self, context.config_name(), action)?; + self.chords.primary_hint(action_id, bindings) + } + /// Built-in keymap defaults. /// /// Some actions intentionally include compatibility variants (for example @@ -939,6 +1104,7 @@ impl RuntimeKeymap { toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], toggle_side_conversation: default_bindings![ctrl(KeyCode::Char('/'))], }, + chords: Arc::default(), chat: ChatKeymap { interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![ @@ -1132,6 +1298,7 @@ impl RuntimeKeymap { jump_bottom: default_bindings![plain(KeyCode::End)], close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], + chord_hints: Arc::default(), }, list: ListKeymap { move_up: default_bindings![ @@ -1154,6 +1321,7 @@ impl RuntimeKeymap { jump_bottom: default_bindings![plain(KeyCode::End)], accept: default_bindings![plain(KeyCode::Enter)], cancel: default_bindings![plain(KeyCode::Esc)], + chord_hints: Arc::default(), }, approval: ApprovalKeymap { open_fullscreen: default_bindings![ @@ -1170,6 +1338,7 @@ impl RuntimeKeymap { deny: default_bindings![plain(KeyCode::Char('d'))], decline: default_bindings![plain(KeyCode::Esc), plain(KeyCode::Char('n'))], cancel: default_bindings![plain(KeyCode::Char('c'))], + chord_hints: Arc::default(), }, } } @@ -1983,6 +2152,9 @@ fn resolve_new_default_bindings( fn parse_bindings(spec: &KeybindingsSpec, path: &str) -> Result, String> { let mut parsed = Vec::new(); for raw in spec.specs() { + if raw.as_str().contains(' ') { + continue; + } let binding = parse_keybinding(raw.as_str()).ok_or_else(|| { format!( "Invalid `{path}` = `{}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`. \ diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs new file mode 100644 index 0000000000..08b742d6da --- /dev/null +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -0,0 +1,343 @@ +//! 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; + +/// Config context in which a keymap action is active. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum KeymapContext { + Global, + Chat, + Composer, + Editor, + VimNormal, + VimOperator, + VimTextObject, + Pager, + List, + Approval, +} + +impl KeymapContext { + pub(crate) const fn config_name(self) -> &'static str { + match self { + Self::Global => "global", + Self::Chat => "chat", + Self::Composer => "composer", + Self::Editor => "editor", + Self::VimNormal => "vim_normal", + Self::VimOperator => "vim_operator", + Self::VimTextObject => "vim_text_object", + Self::Pager => "pager", + Self::List => "list", + Self::Approval => "approval", + } + } + + pub(crate) const fn allows_plain_chord_prefix(self) -> bool { + matches!( + self, + Self::VimNormal | Self::VimOperator | Self::VimTextObject + ) + } + + pub(crate) fn overlaps(self, other: Self) -> bool { + if self == other { + return true; + } + + matches!( + (self, other), + (Self::List, Self::Approval) + | (Self::Approval, 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::Composer) + } + + const fn is_main_editor(self) -> bool { + matches!( + self, + Self::Editor | Self::VimNormal | Self::VimOperator | 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 { + 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! define_runtime_action_bindings { + ($($context:literal => $context_id:ident, $group:ident, $config_group:ident [$($action:ident),+ $(,)?]),+ $(,)?) => { + /// Resolve a config context/action pair to its runtime identity. + pub(crate) fn keymap_action_id( + context: &str, + action: &str, + ) -> Option { + match (context, action) { + $( + $( + ($context, stringify!($action)) => Some(KeymapActionId { + context: KeymapContext::$context_id, + action: stringify!($action), + }), + )+ + )+ + _ => None, + } + } + + /// Return the root-config slot for one runtime action. + pub(super) fn configured_binding_for_action( + keymap: &TuiKeymap, + action: KeymapActionId, + ) -> Option<&Option> { + match (action.context.config_name(), action.action) { + $( + $( + ($context, stringify!($action)) => { + Some(&keymap.$config_group.$action) + } + )+ + )+ + _ => None, + } + } + + /// Iterate over every stable runtime action identity. + pub(crate) fn keymap_action_ids() -> impl Iterator { + [ + $( + $( + 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) { + $( + $( + ($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_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> { + [ + $( + $( + 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_transcript, + open_external_editor, + copy, + clear_terminal, + toggle_vim_mode, + toggle_fast_mode, + toggle_raw_output, + toggle_side_conversation, + ], + "chat" => Chat, chat, chat [ + interrupt_turn, + decrease_reasoning_effort, + increase_reasoning_effort, + edit_queued_message, + ], + "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, + move_left, + move_right, + move_up, + move_down, + move_word_forward, + move_word_backward, + move_word_end, + move_line_start, + move_line_end, + delete_char, + substitute_char, + delete_to_line_end, + change_to_line_end, + yank_line, + paste_after, + start_delete_operator, + start_yank_operator, + start_change_operator, + cancel_operator, + ], + "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, + 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, + ], + "list" => List, list, list [ + move_up, + move_down, + move_left, + move_right, + page_up, + page_down, + jump_top, + jump_bottom, + accept, + cancel, + ], + "approval" => Approval, approval, approval [ + open_fullscreen, + open_thread, + approve, + approve_for_session, + approve_for_prefix, + deny, + decline, + cancel, + ], +} diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs new file mode 100644 index 0000000000..22902aaee2 --- /dev/null +++ b/codex-rs/tui/src/keymap/chords.rs @@ -0,0 +1,483 @@ +//! Bounded, context-aware two-stroke shortcuts for TUI actions. +//! +//! Single-event bindings remain in the ordinary runtime keymaps. A completed +//! chord becomes an internal function-key token appended to the target action, +//! so existing handlers remain the only action dispatch table. + +use super::MAIN_RESERVED_BINDINGS; +use super::RuntimeKeymap; +use super::TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS; +use super::bindings::KeymapActionId; +use super::bindings::KeymapContext; +use super::bindings::configured_binding_for_action; +use super::bindings::keymap_action_ids; +use super::bindings::push_binding_for_action; +use super::parse_keybinding; +use super::runtime_action_bindings; +use crate::key_hint::KeyBinding; +use crate::key_hint::ctrl; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use std::time::Duration; +use tokio::time::Instant; + +const FIRST_DISPATCH_FUNCTION_KEY: u8 = 128; +const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX; +const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ + ("cancel", ctrl(KeyCode::Char('c'))), + ("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))), + ("resume_picker.toggle_expansion", ctrl(KeyCode::Char('e'))), + ("resume_picker.toggle_density", ctrl(KeyCode::Char('o'))), +]; + +/// Time allowed between the first and second strokes of a key chord. +pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); + +/// Compact set of keymap contexts that share one active input path. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct KeymapContextSet(u16); + +impl KeymapContextSet { + pub(crate) const fn new(context: KeymapContext) -> Self { + Self(context_bit(context)) + } + + pub(crate) const fn with(self, context: KeymapContext) -> Self { + Self(self.0 | context_bit(context)) + } + + pub(crate) const fn contains(self, context: KeymapContext) -> bool { + self.0 & context_bit(context) != 0 + } +} + +const fn context_bit(context: KeymapContext) -> u16 { + 1 << match context { + KeymapContext::Global => 0, + KeymapContext::Chat => 1, + KeymapContext::Composer => 2, + KeymapContext::Editor => 3, + KeymapContext::VimNormal => 4, + KeymapContext::VimOperator => 5, + KeymapContext::VimTextObject => 6, + KeymapContext::Pager => 7, + KeymapContext::List => 8, + KeymapContext::Approval => 9, + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) struct KeyChord { + pub(crate) prefix: KeyBinding, + pub(crate) completion: KeyBinding, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RuntimeChordBinding { + pub(crate) action: KeymapActionId, + pub(crate) chord: KeyChord, + spec: String, +} + +/// Chord bindings plus configured alternatives in declaration order. +#[derive(Clone, Debug, Default)] +pub(crate) struct RuntimeChordKeymap { + pub(crate) bindings: Vec, + configured_specs: Vec<(KeymapActionId, Vec)>, +} + +impl RuntimeChordKeymap { + pub(super) fn from_config(keymap: &TuiKeymap) -> Result { + let mut keymap_chords = Self::default(); + for action in keymap_action_ids() { + let Some(configured) = effective_configured_binding(keymap, action) else { + continue; + }; + let mut configured_specs = Vec::new(); + for spec in configured.specs() { + let raw = spec.as_str(); + if configured_specs.iter().any(|configured| configured == raw) { + continue; + } + if let Some((prefix, completion)) = raw.split_once(' ') { + let invalid_binding = || { + format!( + "Invalid `{}` = `{raw}`. Use a single key such as `ctrl-a` \ +or a two-stroke chord such as `ctrl-x ctrl-t`.", + action.config_path() + ) + }; + keymap_chords.bindings.push(RuntimeChordBinding { + action, + chord: KeyChord { + prefix: normalize_chord_binding( + parse_keybinding(prefix).ok_or_else(invalid_binding)?, + ), + completion: normalize_chord_binding( + parse_keybinding(completion).ok_or_else(invalid_binding)?, + ), + }, + spec: raw.to_string(), + }); + } + configured_specs.push(raw.to_string()); + } + keymap_chords + .configured_specs + .push((action, configured_specs)); + } + Ok(keymap_chords) + } + + pub(crate) fn configured_specs(&self, action: KeymapActionId) -> Option<&[String]> { + self.configured_specs + .iter() + .find_map(|(configured_action, specs)| { + (*configured_action == action).then_some(specs.as_slice()) + }) + } + + /// Return the user's first configured shortcut without exposing dispatch tokens. + pub(crate) fn primary_hint( + &self, + action: KeymapActionId, + bindings: &[KeyBinding], + ) -> Option { + if let Some(spec) = self + .configured_specs(action) + .and_then(|specs| specs.first()) + { + return if let Some((prefix, completion)) = spec.split_once(' ') { + Some(crate::key_hint::ShortcutHint::Chord { + prefix: parse_keybinding(prefix)?, + completion: parse_keybinding(completion)?, + }) + } else { + parse_keybinding(spec).map(crate::key_hint::ShortcutHint::Single) + }; + } + + super::primary_binding(bindings).map(crate::key_hint::ShortcutHint::Single) + } +} + +/// Outcome of routing one physical key through the chord state machine. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyChordMatch { + PassThrough, + Pending(KeyBinding), + Completed(KeyEvent), + Cancelled, + Ignored, +} + +#[derive(Clone, Copy, Debug)] +struct PendingChord { + prefix: KeyBinding, + contexts: KeymapContextSet, + started_at: Instant, +} + +/// Tracks one pending two-stroke chord without buffering ordinary input. +#[derive(Debug, Default)] +pub(crate) struct KeyChordMatcher { + pending: Option, +} + +impl KeyChordMatcher { + pub(crate) fn is_pending(&self) -> bool { + self.pending.is_some() + } + + pub(crate) fn cancel(&mut self) -> bool { + self.pending.take().is_some() + } + + pub(crate) fn expire(&mut self, contexts: KeymapContextSet, now: Instant) -> bool { + if self.pending.is_some_and(|pending| { + pending.contexts != contexts + || now.saturating_duration_since(pending.started_at) >= KEY_CHORD_TIMEOUT + }) { + self.pending = None; + return true; + } + false + } + + pub(crate) fn advance( + &mut self, + key_event: KeyEvent, + keymap: &RuntimeChordKeymap, + contexts: KeymapContextSet, + now: Instant, + ) -> KeyChordMatch { + if is_dispatch_token_event(key_event) { + return KeyChordMatch::Ignored; + } + + self.expire(contexts, now); + + if self.pending.is_some() && key_event.kind != KeyEventKind::Press { + return KeyChordMatch::Ignored; + } + if key_event.kind != KeyEventKind::Press { + return KeyChordMatch::PassThrough; + } + + if let Some(pending) = self.pending.take() { + if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) { + return KeyChordMatch::Cancelled; + } + if let Some(binding) = keymap.bindings.iter().find(|binding| { + contexts.contains(binding.action.context) + && binding.chord.prefix == pending.prefix + && chord_stroke_matches(binding.chord.completion, key_event) + }) { + let Some(dispatch_event) = dispatch_event(binding.action) else { + return KeyChordMatch::Ignored; + }; + return KeyChordMatch::Completed(dispatch_event); + } + } + + if let Some(prefix) = keymap + .bindings + .iter() + .find(|binding| { + contexts.contains(binding.action.context) + && chord_stroke_matches(binding.chord.prefix, key_event) + }) + .map(|binding| binding.chord.prefix) + { + self.pending = Some(PendingChord { + prefix, + contexts, + started_at: now, + }); + return KeyChordMatch::Pending(prefix); + } + + KeyChordMatch::PassThrough + } +} + +pub(super) fn install_dispatch_bindings(keymap: &mut RuntimeKeymap) -> Result<(), String> { + let actions = keymap + .chords + .bindings + .iter() + .map(|binding| binding.action) + .collect::>(); + for action in actions { + let binding = dispatch_binding(action).ok_or_else(|| { + format!( + "Cannot dispatch `{}`: the keymap action inventory exceeds {} internal tokens.", + action.config_path(), + LAST_DISPATCH_FUNCTION_KEY - FIRST_DISPATCH_FUNCTION_KEY + 1, + ) + })?; + if !push_binding_for_action(keymap, action, binding) { + return Err(format!( + "Cannot dispatch unknown keymap action `{}`.", + action.config_path() + )); + } + } + Ok(()) +} + +fn dispatch_binding(action: KeymapActionId) -> Option { + let index = keymap_action_ids().position(|candidate| candidate == action)?; + let function_key = FIRST_DISPATCH_FUNCTION_KEY.checked_add(u8::try_from(index).ok()?)?; + Some(KeyBinding::new( + KeyCode::F(function_key), + KeyModifiers::NONE, + )) +} + +fn dispatch_event(action: KeymapActionId) -> Option { + let (code, modifiers) = dispatch_binding(action)?.parts(); + Some(KeyEvent::new(code, modifiers)) +} + +pub(crate) fn is_dispatch_token(binding: KeyBinding) -> bool { + matches!( + binding.parts(), + ( + KeyCode::F(FIRST_DISPATCH_FUNCTION_KEY..=LAST_DISPATCH_FUNCTION_KEY), + _ + ) + ) +} + +fn is_dispatch_token_event(event: KeyEvent) -> bool { + matches!( + event.code, + KeyCode::F(FIRST_DISPATCH_FUNCTION_KEY..=LAST_DISPATCH_FUNCTION_KEY) + ) +} + +fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool { + let ctrl_slash = crate::key_hint::ctrl(KeyCode::Char('/')); + let ctrl_seven = crate::key_hint::ctrl(KeyCode::Char('7')); + + binding.is_press(key_event) + || binding.parts() == ctrl_slash.parts() && ctrl_seven.is_press(key_event) + || binding.parts() == ctrl_seven.parts() && ctrl_slash.is_press(key_event) +} + +fn effective_configured_binding( + keymap: &TuiKeymap, + action: KeymapActionId, +) -> Option<&KeybindingsSpec> { + if let Some(configured) = configured_binding_for_action(keymap, action)?.as_ref() { + return Some(configured); + } + if action.context != KeymapContext::Composer { + return None; + } + match action.action { + "submit" => keymap.global.submit.as_ref(), + "queue" => keymap.global.queue.as_ref(), + "toggle_shortcuts" => keymap.global.toggle_shortcuts.as_ref(), + _ => None, + } +} + +pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding { + if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() { + crate::key_hint::ctrl(KeyCode::Char('/')) + } else { + binding + } +} + +/// Reject chords that would shadow an active single binding or fixed shortcut. +pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), String> { + for (index, binding) in keymap.chords.bindings.iter().enumerate() { + validate_binding_shape(binding)?; + validate_reserved_strokes(binding)?; + + if let Some(conflict) = runtime_action_bindings(keymap) + .filter(|candidate| binding.action.context.overlaps(candidate.id.context)) + .find(|candidate| { + candidate.bindings.iter().any(|single| { + normalize_chord_binding(*single).parts() == binding.chord.prefix.parts() + }) + }) + { + return Err(format!( + "Ambiguous `{}` = `{}`: its prefix shadows `{}`. \ +Unbind or remap the existing shortcut before using it as a chord prefix.", + binding.action.config_path(), + binding.spec, + conflict.id.config_path(), + )); + } + + for previous in &keymap.chords.bindings[..index] { + if previous.action != binding.action + && previous.action.context.overlaps(binding.action.context) + && previous.chord == binding.chord + { + return Err(format!( + "Ambiguous `{}` = `{}`: the same chord is already assigned to `{}`. \ +Choose a unique chord and retry.", + binding.action.config_path(), + binding.spec, + previous.action.config_path(), + )); + } + } + } + + Ok(()) +} + +fn validate_binding_shape(binding: &RuntimeChordBinding) -> Result<(), String> { + let path = binding.action.config_path(); + let (prefix_key, prefix_modifiers) = binding.chord.prefix.parts(); + + if matches!(prefix_key, KeyCode::Char(_)) + && crate::key_hint::is_altgr(prefix_modifiers) + && !binding.action.context.allows_plain_chord_prefix() + { + return Err(format!( + "Invalid `{path}` = `{}`: a ctrl-alt character prefix may be AltGr text input on \ +Windows. Choose a different chord and retry.", + binding.spec + )); + } + + if matches!(prefix_key, KeyCode::Char(_)) + && !crate::key_hint::has_ctrl_or_alt(prefix_modifiers) + && !binding.action.context.allows_plain_chord_prefix() + { + return Err(format!( + "Invalid `{path}` = `{}`: a chord prefix outside Vim must use ctrl, \ +alt, or a non-character key so ordinary text input is not intercepted.", + binding.spec + )); + } + + Ok(()) +} + +fn validate_reserved_strokes(binding: &RuntimeChordBinding) -> Result<(), String> { + let path = binding.action.config_path(); + let strokes = [ + binding.chord.prefix.parts(), + binding.chord.completion.parts(), + ]; + + if strokes.contains(&(KeyCode::Esc, KeyModifiers::NONE)) { + return Err(format!( + "Ambiguous `{path}` = `{}`: plain `esc` is reserved for cancelling a pending chord.", + binding.spec + )); + } + + #[cfg(unix)] + if strokes.contains(&crate::key_hint::ctrl(KeyCode::Char('z')).parts()) { + return Err(format!( + "Ambiguous `{path}` = `{}`: `ctrl-z` is reserved for suspending the terminal on Unix. \ +Choose a different chord and retry.", + binding.spec + )); + } + + let reserved = match binding.action.context { + KeymapContext::Pager => TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, + KeymapContext::Global + | KeymapContext::Chat + | KeymapContext::Composer + | KeymapContext::Editor + | KeymapContext::VimNormal + | KeymapContext::VimOperator + | KeymapContext::VimTextObject => MAIN_RESERVED_BINDINGS, + KeymapContext::List => LIST_RESERVED_BINDINGS, + KeymapContext::Approval => &LIST_RESERVED_BINDINGS[..1], + }; + if let Some((reserved_action, _)) = reserved.iter().find(|(_, reserved)| { + binding.chord.prefix.parts() == reserved.parts() + || binding.chord.completion.parts() == reserved.parts() + && (binding.action.context != KeymapContext::List + || reserved.parts() == ctrl(KeyCode::Char('c')).parts()) + }) { + return Err(format!( + "Ambiguous `{path}` = `{}`: the chord uses the key reserved by \ +`{reserved_action}`. Choose a different chord and retry.", + binding.spec + )); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "chords_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs new file mode 100644 index 0000000000..dd0138696b --- /dev/null +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -0,0 +1,400 @@ +use super::KEY_CHORD_TIMEOUT; +use super::KeyChordMatch; +use super::KeyChordMatcher; +use super::KeymapContextSet; +use super::dispatch_binding; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::RuntimeKeymap; +use crate::keymap::bindings::KeymapContext; +use crate::keymap::bindings::keymap_action_ids; +use crate::keymap::keymap_action_id; +use crate::keymap::user_bindings; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use tokio::time::Instant; + +fn binding(spec: &str) -> KeybindingsSpec { + KeybindingsSpec::One(KeybindingSpec(spec.to_string())) +} + +fn bindings(specs: &[&str]) -> KeybindingsSpec { + KeybindingsSpec::Many( + specs + .iter() + .map(|spec| KeybindingSpec((*spec).to_string())) + .collect(), + ) +} + +#[test] +fn rejects_fixed_resume_picker_keys_inside_list_chords() { + for (chord, reserved_action) in [ + ("ctrl-c home", "cancel"), + ("ctrl-t home", "resume_picker.toggle_transcript"), + ("ctrl-e home", "resume_picker.toggle_expansion"), + ("ctrl-o home", "resume_picker.toggle_density"), + ] { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(binding(chord)); + + let error = RuntimeKeymap::from_config(&config) + .expect_err("fixed picker actions must remain available"); + + assert!(error.contains(reserved_action), "{error}"); + } +} + +#[test] +fn rejects_ctrl_c_inside_approval_chords() { + let mut config = TuiKeymap::default(); + config.approval.approve = Some(binding("ctrl-x ctrl-c")); + + let error = + RuntimeKeymap::from_config(&config).expect_err("Ctrl+C must continue cancelling approvals"); + + assert!(error.contains("cancel"), "{error}"); +} + +#[test] +fn resolves_chords_for_actions_in_different_contexts() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(bindings(&["ctrl-t", "ctrl-x ctrl-t"])); + config.composer.submit = Some(binding("ctrl-x enter")); + config.list.jump_top = Some(binding("ctrl-x home")); + + let actual = RuntimeKeymap::from_config(&config).expect("valid chord keymap"); + + assert_eq!( + user_bindings(&actual.app.open_transcript), + vec![key_hint::ctrl(KeyCode::Char('t'))] + ); + for (context, action, expected) in [ + ( + "global", + "open_transcript", + vec!["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()], + ), + ("composer", "submit", vec!["ctrl-x enter".to_string()]), + ("list", "jump_top", vec!["ctrl-x home".to_string()]), + ] { + let action = keymap_action_id(context, action).expect("known action"); + assert_eq!( + actual.chords.configured_specs(action), + Some(expected.as_slice()) + ); + } +} + +#[test] +fn composer_chord_inherits_global_fallback() { + let mut config = TuiKeymap::default(); + config.global.submit = Some(binding("ctrl-x enter")); + + let actual = RuntimeKeymap::from_config(&config).expect("valid global fallback chord"); + let action = keymap_action_id("composer", "submit").expect("known action"); + + assert_eq!( + actual.chords.configured_specs(action), + Some(["ctrl-x enter".to_string()].as_slice()) + ); +} + +#[test] +fn allows_shared_prefixes_with_distinct_completions() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-x ctrl-t")); + config.global.copy = Some(binding("ctrl-x ctrl-o")); + + RuntimeKeymap::from_config(&config).expect("prefix trie can branch on the second stroke"); +} + +#[test] +fn allows_plain_prefixes_in_vim_and_repeated_chords_in_all_contexts() { + let mut config = TuiKeymap::default(); + config.vim_normal.move_line_start = Some(binding("g g")); + config.vim_operator.motion_line_start = Some(binding("g g")); + config.global.open_transcript = Some(binding("ctrl-t ctrl-t")); + + RuntimeKeymap::from_config(&config) + .expect("Vim owns printable prefixes and all contexts support repeated chords"); +} + +#[test] +fn rejects_plain_printable_prefixes_outside_vim() { + let mut config = TuiKeymap::default(); + config.composer.submit = Some(binding("x enter")); + + let error = RuntimeKeymap::from_config(&config).expect_err("typing must not start a chord"); + + assert!(error.contains("outside Vim"), "{error}"); + assert!(error.contains("ordinary text input"), "{error}"); +} + +#[test] +fn rejects_prefixes_that_shadow_overlapping_single_bindings() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-k ctrl-t")); + + let error = RuntimeKeymap::from_config(&config).expect_err("ctrl-k already edits text"); + + assert!(error.contains("editor.kill_line_end"), "{error}"); + assert!(error.contains("ctrl-k ctrl-t"), "{error}"); +} + +#[test] +fn permits_the_same_chord_in_non_overlapping_contexts() { + let mut config = TuiKeymap::default(); + config.editor.move_line_start = Some(binding("ctrl-x home")); + config.list.jump_top = Some(binding("ctrl-x home")); + + RuntimeKeymap::from_config(&config) + .expect("editor and list contexts are never active together"); +} + +#[test] +fn rejects_duplicate_chords_in_overlapping_contexts() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-x ctrl-t")); + config.composer.submit = Some(binding("ctrl-x ctrl-t")); + + let error = + RuntimeKeymap::from_config(&config).expect_err("one active chord must identify one action"); + + assert!(error.contains("global.open_transcript"), "{error}"); + assert!(error.contains("composer.submit"), "{error}"); +} + +#[test] +fn rejects_duplicate_chat_and_list_chords_used_by_request_user_input() { + let mut config = TuiKeymap::default(); + config.chat.interrupt_turn = Some(binding("ctrl-x home")); + config.list.jump_top = Some(binding("ctrl-x home")); + + let error = + RuntimeKeymap::from_config(&config).expect_err("request input activates both contexts"); + + assert!(error.contains("chat.interrupt_turn"), "{error}"); + assert!(error.contains("list.jump_top"), "{error}"); +} + +#[test] +fn normalizes_ctrl_slash_terminal_aliases() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-7 ctrl-t")); + + let error = RuntimeKeymap::from_config(&config) + .expect_err("Ctrl-7 must remain the Ctrl-/ side-conversation alias"); + + assert!(error.contains("global.toggle_side_conversation"), "{error}"); +} + +#[test] +fn rejects_alias_equivalent_duplicate_chords() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-x ctrl-/")); + config.global.copy = Some(binding("ctrl-x ctrl-7")); + config.global.toggle_side_conversation = Some(KeybindingsSpec::Many(Vec::new())); + + let error = RuntimeKeymap::from_config(&config).expect_err("aliases must identify one action"); + + assert!(error.contains("global.open_transcript"), "{error}"); + assert!(error.contains("global.copy"), "{error}"); +} + +#[test] +fn rejects_plain_escape_inside_a_chord() { + let mut config = TuiKeymap::default(); + config.list.cancel = Some(binding("ctrl-x esc")); + + let error = RuntimeKeymap::from_config(&config).expect_err("Esc cancels pending chords"); + + assert!(error.contains("reserved for cancelling"), "{error}"); +} + +#[test] +fn rejects_reserved_interrupt_keys_inside_a_main_chord() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-x ctrl-c")); + + let error = RuntimeKeymap::from_config(&config).expect_err("ctrl-c must remain available"); + + assert!(error.contains("fixed.interrupt_or_quit"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn rejects_unix_suspend_key_in_any_chord_stroke() { + for chord in ["ctrl-z ctrl-t", "ctrl-x ctrl-z"] { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(binding(chord)); + + let error = RuntimeKeymap::from_config(&config) + .expect_err("the Unix suspend key must not be consumed"); + + assert!(error.contains("suspending"), "{error}"); + } +} + +#[cfg(windows)] +#[test] +fn rejects_windows_altgr_prefixes_outside_vim() { + let mut config = TuiKeymap::default(); + config.composer.submit = Some(binding("ctrl-alt-x enter")); + + let error = + RuntimeKeymap::from_config(&config).expect_err("AltGr may be ordinary Windows text input"); + + assert!(error.contains("AltGr"), "{error}"); +} + +#[test] +fn dispatch_tokens_are_unique_and_cover_the_action_inventory() { + let mut tokens = HashSet::new(); + let actions = keymap_action_ids().collect::>(); + + for action in &actions { + assert!( + tokens.insert(dispatch_binding(*action).expect("action inventory must fit token band")), + "duplicate token for {}", + action.config_path() + ); + } + + assert_eq!(tokens.len(), actions.len()); +} + +#[test] +fn physical_dispatch_tokens_do_not_clear_pending_chords() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-x ctrl-t")); + let keymap = RuntimeKeymap::from_config(&config).expect("valid chord keymap"); + let contexts = KeymapContextSet::new(KeymapContext::Global); + let now = Instant::now(); + let mut matcher = KeyChordMatcher::default(); + + assert!(matches!( + matcher.advance( + key_event(key_hint::ctrl(KeyCode::Char('x'))), + &keymap.chords, + contexts, + now, + ), + KeyChordMatch::Pending(_) + )); + + let action = keymap_action_id("global", "open_transcript").expect("known keymap action"); + let token = dispatch_binding(action).expect("internal dispatch token"); + assert_eq!( + matcher.advance(key_event(token), &keymap.chords, contexts, now), + KeyChordMatch::Ignored + ); + assert!(matcher.is_pending()); + assert!(matches!( + matcher.advance( + key_event(key_hint::ctrl(KeyCode::Char('t'))), + &keymap.chords, + contexts, + now, + ), + KeyChordMatch::Completed(_) + )); +} + +#[test] +fn matching_chords_dispatches_the_active_context_action() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("ctrl-t ctrl-t")); + config.editor.move_line_start = Some(binding("ctrl-x home")); + config.list.jump_top = Some(binding("ctrl-x home")); + let runtime = RuntimeKeymap::from_config(&config).expect("non-overlapping chord contexts"); + let now = Instant::now(); + + for (context, prefix, completion, target, other) in [ + ( + KeymapContext::Global, + key_event(key_hint::ctrl(KeyCode::Char('t'))), + key_event(key_hint::ctrl(KeyCode::Char('t'))), + runtime.app.open_transcript.as_slice(), + runtime.app.copy.as_slice(), + ), + ( + KeymapContext::Editor, + key_event(key_hint::ctrl(KeyCode::Char('x'))), + KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE), + runtime.editor.move_line_start.as_slice(), + runtime.list.jump_top.as_slice(), + ), + ( + KeymapContext::List, + key_event(key_hint::ctrl(KeyCode::Char('x'))), + KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE), + runtime.list.jump_top.as_slice(), + runtime.editor.move_line_start.as_slice(), + ), + ] { + let mut matcher = KeyChordMatcher::default(); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context), now,), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(dispatch_event) = matcher.advance( + completion, + &runtime.chords, + KeymapContextSet::new(context), + now, + ) else { + panic!("second stroke must complete the active context chord"); + }; + assert!(target.is_pressed(dispatch_event)); + assert!(!other.is_pressed(dispatch_event)); + } +} + +#[test] +fn matcher_cancels_on_escape_context_change_and_timeout() { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(binding("ctrl-x ctrl-t")); + let runtime = RuntimeKeymap::from_config(&config).expect("valid list chord"); + let list = KeymapContextSet::new(KeymapContext::List); + let pager = KeymapContextSet::new(KeymapContext::Pager); + let prefix = key_event(key_hint::ctrl(KeyCode::Char('x'))); + let now = Instant::now(); + + let mut matcher = KeyChordMatcher::default(); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, list, now), + KeyChordMatch::Pending(_) + )); + assert_eq!( + matcher.advance( + KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE), + &runtime.chords, + list, + now, + ), + KeyChordMatch::Cancelled + ); + + assert!(matches!( + matcher.advance(prefix, &runtime.chords, list, now), + KeyChordMatch::Pending(_) + )); + assert!(matcher.expire(pager, now)); + + assert!(matches!( + matcher.advance(prefix, &runtime.chords, list, now), + KeyChordMatch::Pending(_) + )); + assert!(matcher.expire(list, now + KEY_CHORD_TIMEOUT)); +} + +fn key_event(binding: crate::key_hint::KeyBinding) -> KeyEvent { + let (code, modifiers) = binding.parts(); + KeyEvent::new(code, modifiers) +} diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index a88f524951..e6737249b2 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -8,20 +8,22 @@ //! user. //! //! The flow is intentionally split into three steps: choose an action, choose -//! whether to replace/add/remove a binding, then capture exactly one terminal -//! key event. Validation happens after capture by reusing runtime keymap -//! resolution, so conflict rules stay centralized in `keymap.rs` instead of -//! being duplicated in the UI. +//! whether to replace/add/remove a binding, then capture either one terminal +//! key event or a bounded two-stroke key chord. Validation happens after +//! capture by reusing runtime keymap resolution, so conflict rules stay +//! centralized in `keymap.rs` instead of being duplicated in the UI. //! //! This module does not persist config files directly. It emits app events with //! the edited config so the app layer can decide how to save, reload, and //! surface errors. mod actions; +mod capture; mod debug; mod picker; pub(crate) use actions::KeymapActionFilter; +use capture::KeymapCaptureView; pub(crate) use debug::build_keymap_debug_view; pub(crate) use picker::KEYMAP_PICKER_VIEW_ID; #[cfg(test)] @@ -37,20 +39,14 @@ use codex_config::types::MAX_FUNCTION_KEY; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; use crate::app_event::AppEvent; +use crate::app_event::KeymapCaptureMode; use crate::app_event::KeymapEditIntent; use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; @@ -58,13 +54,13 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; +use crate::keymap::bindings_for_action; +use crate::keymap::keymap_action_id; use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; use actions::KEYMAP_ACTIONS; use actions::action_label; use actions::binding_slot; -use actions::bindings_for_action; -use actions::format_binding_summary; +use actions::format_action_binding_summary; #[cfg(test)] use debug::KeymapDebugView; @@ -105,12 +101,14 @@ fn open_capture_action( context: String, action: String, intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, ) -> Box { Box::new(move |tx| { tx.send(AppEvent::OpenKeymapCapture { context: context.clone(), action: action.clone(), intent: intent.clone(), + capture_mode, }); }) } @@ -122,6 +120,7 @@ fn action_menu_item( context: &str, action: &str, intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, ) -> SelectionItem { SelectionItem { name: name.to_string(), @@ -131,6 +130,7 @@ fn action_menu_item( context.to_string(), action.to_string(), intent, + capture_mode, )], ..Default::default() } @@ -208,6 +208,7 @@ pub(crate) fn build_keymap_action_menu_params( &context, &action, KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::SingleKey, )); } 1 => { @@ -218,6 +219,7 @@ pub(crate) fn build_keymap_action_menu_params( &context, &action, KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::SingleKey, )); items.push(action_menu_item( "Add alternate binding", @@ -226,6 +228,7 @@ pub(crate) fn build_keymap_action_menu_params( &context, &action, KeymapEditIntent::AddAlternate, + KeymapCaptureMode::SingleKey, )); } _ => { @@ -252,6 +255,7 @@ pub(crate) fn build_keymap_action_menu_params( &context, &action, KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::SingleKey, )); items.push(action_menu_item( "Add alternate binding", @@ -260,9 +264,40 @@ pub(crate) fn build_keymap_action_menu_params( &context, &action, KeymapEditIntent::AddAlternate, + KeymapCaptureMode::SingleKey, )); } } + if active_binding_count == 0 { + items.push(action_menu_item( + "Set key chord", + "Capture two consecutive keys for this action.", + "Capture a two-stroke key chord.".to_string(), + &context, + &action, + KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::Chord, + )); + } else { + items.push(action_menu_item( + "Replace with key chord", + "Replace current bindings with a two-stroke key chord.", + format!("Capture two keys and replace `{current_binding}`."), + &context, + &action, + KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::Chord, + )); + items.push(action_menu_item( + "Add alternate key chord", + "Keep current bindings and add a two-stroke key chord.", + format!("Capture two keys and keep `{current_binding}`."), + &context, + &action, + KeymapEditIntent::AddAlternate, + KeymapCaptureMode::Chord, + )); + } items.push(SelectionItem { name: "Remove custom binding".to_string(), description: custom_binding.then(|| "Restore the default keymap binding.".to_string()), @@ -321,26 +356,39 @@ pub(crate) fn build_keymap_replace_binding_menu_params( let items = bindings .into_iter() - .map(|binding| { - let capture_context = context.clone(); - let capture_action = action.clone(); - let old_key = binding.clone(); - SelectionItem { - name: binding.clone(), - description: Some("Replace this binding.".to_string()), - selected_description: Some(format!("Capture a new key to replace `{binding}`.")), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenKeymapCapture { - context: capture_context.clone(), - action: capture_action.clone(), - intent: KeymapEditIntent::ReplaceOne { - old_key: old_key.clone(), + .flat_map(|binding| { + [ + SelectionItem { + name: binding.clone(), + description: Some("Replace this binding.".to_string()), + selected_description: Some(format!( + "Capture a new key to replace `{binding}`." + )), + actions: vec![open_capture_action( + context.clone(), + action.clone(), + KeymapEditIntent::ReplaceOne { + old_key: binding.clone(), }, - }); - })], - dismiss_on_select: true, - ..Default::default() - } + KeymapCaptureMode::SingleKey, + )], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: format!("{binding} (key chord)"), + description: Some("Replace this binding with a key chord.".to_string()), + selected_description: Some(format!("Capture two keys to replace `{binding}`.")), + actions: vec![open_capture_action( + context.clone(), + action.clone(), + KeymapEditIntent::ReplaceOne { old_key: binding }, + KeymapCaptureMode::Chord, + )], + dismiss_on_select: true, + ..Default::default() + }, + ] }) .collect(); @@ -361,9 +409,11 @@ pub(crate) fn build_keymap_conflict_params( intent: KeymapEditIntent, error: String, ) -> SelectionViewParams { - let retry_context = context.clone(); - let retry_action = action.clone(); - let retry_intent = intent; + let capture_mode = if key.contains(' ') { + KeymapCaptureMode::Chord + } else { + KeymapCaptureMode::SingleKey + }; SelectionViewParams { title: Some("Shortcut Conflict".to_string()), subtitle: Some(format!("{context}.{action} cannot use `{key}`.")), @@ -373,13 +423,7 @@ pub(crate) fn build_keymap_conflict_params( SelectionItem { name: "Pick another key".to_string(), description: Some("Return to key capture for this action.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenKeymapCapture { - context: retry_context.clone(), - action: retry_action.clone(), - intent: retry_intent.clone(), - }); - })], + actions: vec![open_capture_action(context, action, intent, capture_mode)], dismiss_on_select: true, ..Default::default() }, @@ -405,17 +449,17 @@ pub(crate) fn build_keymap_capture_view( context: String, action: String, intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, runtime_keymap: &RuntimeKeymap, app_event_tx: AppEventSender, ) -> KeymapCaptureView { - let current_binding = format_binding_summary( - bindings_for_action(runtime_keymap, &context, &action).unwrap_or(&[]), - ); + let current_binding = format_action_binding_summary(runtime_keymap, &context, &action); let label = action_label(&action); KeymapCaptureView::new( context, action, intent, + capture_mode, label, current_binding, app_event_tx, @@ -539,6 +583,12 @@ pub(crate) fn active_binding_specs( context: &str, action: &str, ) -> Result, String> { + if let Some(action_id) = keymap_action_id(context, action) + && let Some(specs) = runtime_keymap.chords.configured_specs(action_id) + { + return Ok(specs.to_vec()); + } + let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| { format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") })?; @@ -583,125 +633,6 @@ fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result Ok(slot.is_some()) } -/// Bottom-pane view that captures a single key event for a pending `/keymap` edit. -/// -/// The view is deliberately transient: it renders instructions, accepts one -/// keypress, and emits the captured key to the app layer. It does not mutate -/// config itself, because mutation needs the latest runtime keymap to detect -/// conflicts and stale selections. -pub(crate) struct KeymapCaptureView { - context: String, - action: String, - intent: KeymapEditIntent, - label: String, - current_binding: String, - app_event_tx: AppEventSender, - complete: bool, - error_message: Option, -} - -impl KeymapCaptureView { - fn new( - context: String, - action: String, - intent: KeymapEditIntent, - label: String, - current_binding: String, - app_event_tx: AppEventSender, - ) -> Self { - Self { - context, - action, - intent, - label, - current_binding, - app_event_tx, - complete: false, - error_message: None, - } - } - - fn lines(&self, width: u16) -> Vec> { - let wrap_width = usize::from(width.max(1)); - let mut lines = vec![ - Line::from("Remap Shortcut".bold()), - Line::from(vec![ - "Action: ".dim(), - self.label.clone().into(), - " ".into(), - format!("{}.{}", self.context, self.action).dim(), - ]), - Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]), - Line::from("Press the new key now. Esc cancels.".dim()), - ]; - - if let Some(error) = &self.error_message { - lines.push(Line::from("")); - let options = textwrap::Options::new(wrap_width) - .initial_indent("Error: ") - .subsequent_indent(" "); - lines.extend( - textwrap::wrap(error, options) - .into_iter() - .map(|line| Line::from(line.into_owned().red())), - ); - } - - lines - } -} - -impl Renderable for KeymapCaptureView { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.lines(area.width)).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } -} - -impl BottomPaneView for KeymapCaptureView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - if key_event.kind == KeyEventKind::Release { - return; - } - - if key_event.code == KeyCode::Esc { - self.complete = true; - return; - } - - match key_event_to_config_key_spec(key_event) { - Ok(key) => { - self.app_event_tx.send(AppEvent::KeymapCaptured { - context: self.context.clone(), - action: self.action.clone(), - key, - intent: self.intent.clone(), - }); - self.complete = true; - } - Err(error) => { - self.error_message = Some(error); - } - } - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn prefer_esc_to_handle_key_event(&self) -> bool { - true - } -} - fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result { binding_to_config_key_spec(KeyBinding::from_event(key_event)) } @@ -792,12 +723,15 @@ mod tests { use super::*; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; + use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionTab; + use crate::render::renderable::Renderable; use crate::tui::FrameRequester; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; + use ratatui::layout::Rect; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; @@ -1225,6 +1159,45 @@ mod tests { assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120)); } + #[test] + fn picker_global_chord_render_snapshot() { + let keymap = keymap_with_bindings( + &TuiKeymap::default(), + "global", + "open_transcript", + &["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()], + ) + .expect("global chord binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let params = build_keymap_picker_params(&runtime, &keymap); + + assert_snapshot!( + "keymap_picker_global_chord", + render_picker(params, /*width*/ 120) + ); + } + + #[test] + fn configured_global_chord_alternatives_remain_in_order() { + let keymap = keymap_with_bindings( + &TuiKeymap::default(), + "global", + "open_transcript", + &["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()], + ) + .expect("global chord bindings"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + + assert_eq!( + active_binding_specs(&runtime, "global", "open_transcript"), + Ok(vec!["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()]) + ); + assert_eq!( + format_action_binding_summary(&runtime, "global", "open_transcript"), + "ctrl-t, ctrl-x ctrl-t" + ); + } + #[test] fn picker_narrow_uses_compact_tabs() { let runtime = RuntimeKeymap::defaults(); @@ -1365,6 +1338,7 @@ mod tests { "composer".to_string(), "submit".to_string(), KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::SingleKey, "Submit".to_string(), "enter".to_string(), app_event_sender(), @@ -1454,15 +1428,22 @@ mod tests { context, action, intent, + capture_mode, } = rx.try_recv().expect("open capture event") else { panic!("expected OpenKeymapCapture event"); }; assert_eq!(intent, KeymapEditIntent::ReplaceAll); + assert_eq!(capture_mode, KeymapCaptureMode::SingleKey); assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID)); pane.show_view(Box::new(build_keymap_capture_view( - context, action, intent, &runtime, tx, + context, + action, + intent, + capture_mode, + &runtime, + tx, ))); pane.handle_key_event(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::CONTROL)); @@ -1519,15 +1500,22 @@ mod tests { .unwrap(); let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); pane.show_selection_view(build_keymap_picker_params(&runtime, &keymap)); - pane.show_selection_view(build_keymap_action_menu_params( + let action_menu = build_keymap_action_menu_params( "composer".to_string(), "submit".to_string(), &runtime, &keymap, - )); + ); + let remove_idx = action_menu + .items + .iter() + .position(|item| item.name == "Remove custom binding") + .expect("remove custom binding menu item"); + pane.show_selection_view(action_menu); - pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + for _ in 0..remove_idx { + pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let AppEvent::KeymapCleared { context, action } = diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index ed924247be..f47422d8b5 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -5,11 +5,9 @@ //! context label, stable action name, and short description used by the picker //! and action menu. //! -//! The accessors below deliberately mirror the descriptor table for both the -//! editable root config and the resolved runtime keymap. Keeping those matches -//! in one module makes it easier to audit a new action: if it appears in the -//! catalog, it must also be readable from runtime state and writable in -//! `TuiKeymap`. +//! Root-config accessors mirror the descriptor table, while runtime lookups +//! reuse the inventory owned by [`crate::keymap`]. A catalog action must remain +//! both writable in `TuiKeymap` and readable from the shared runtime inventory. use std::collections::BTreeSet; @@ -17,8 +15,8 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use crossterm::event::KeyEvent; -use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; +use crate::keymap::bindings_for_action; #[derive(Clone, Copy, Debug)] pub(super) struct KeymapActionDescriptor { @@ -344,142 +342,25 @@ pub(super) fn binding_slot<'a>( } } -#[rustfmt::skip] -/// Return the resolved runtime bindings for one catalog action. -/// -/// This reads from [`RuntimeKeymap`] rather than root config so UI labels show -/// the actual active binding after defaults, global fallback, explicit -/// unbinding, and duplicate-key validation have already been applied. -pub(super) fn bindings_for_action<'a>( - runtime_keymap: &'a RuntimeKeymap, - context: &str, - action: &str, -) -> Option<&'a [KeyBinding]> { - match (context, action) { - ("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()), - ("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()), - ("global", "copy") => Some(runtime_keymap.app.copy.as_slice()), - ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), - ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), - ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), - ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), - ("global", "toggle_side_conversation") => Some(runtime_keymap.app.toggle_side_conversation.as_slice()), - ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), - ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), - ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), - ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), - ("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()), - ("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()), - ("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()), - ("composer", "history_search_previous") => Some(runtime_keymap.composer.history_search_previous.as_slice()), - ("composer", "history_search_next") => Some(runtime_keymap.composer.history_search_next.as_slice()), - ("editor", "insert_newline") => Some(runtime_keymap.editor.insert_newline.as_slice()), - ("editor", "move_left") => Some(runtime_keymap.editor.move_left.as_slice()), - ("editor", "move_right") => Some(runtime_keymap.editor.move_right.as_slice()), - ("editor", "move_up") => Some(runtime_keymap.editor.move_up.as_slice()), - ("editor", "move_down") => Some(runtime_keymap.editor.move_down.as_slice()), - ("editor", "move_word_left") => Some(runtime_keymap.editor.move_word_left.as_slice()), - ("editor", "move_word_right") => Some(runtime_keymap.editor.move_word_right.as_slice()), - ("editor", "move_line_start") => Some(runtime_keymap.editor.move_line_start.as_slice()), - ("editor", "move_line_end") => Some(runtime_keymap.editor.move_line_end.as_slice()), - ("editor", "delete_backward") => Some(runtime_keymap.editor.delete_backward.as_slice()), - ("editor", "delete_forward") => Some(runtime_keymap.editor.delete_forward.as_slice()), - ("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()), - ("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()), - ("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()), - ("editor", "kill_whole_line") => Some(runtime_keymap.editor.kill_whole_line.as_slice()), - ("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()), - ("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()), - ("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()), - ("vim_normal", "append_after_cursor") => Some(runtime_keymap.vim_normal.append_after_cursor.as_slice()), - ("vim_normal", "append_line_end") => Some(runtime_keymap.vim_normal.append_line_end.as_slice()), - ("vim_normal", "insert_line_start") => Some(runtime_keymap.vim_normal.insert_line_start.as_slice()), - ("vim_normal", "open_line_below") => Some(runtime_keymap.vim_normal.open_line_below.as_slice()), - ("vim_normal", "open_line_above") => Some(runtime_keymap.vim_normal.open_line_above.as_slice()), - ("vim_normal", "move_left") => Some(runtime_keymap.vim_normal.move_left.as_slice()), - ("vim_normal", "move_right") => Some(runtime_keymap.vim_normal.move_right.as_slice()), - ("vim_normal", "move_up") => Some(runtime_keymap.vim_normal.move_up.as_slice()), - ("vim_normal", "move_down") => Some(runtime_keymap.vim_normal.move_down.as_slice()), - ("vim_normal", "move_word_forward") => Some(runtime_keymap.vim_normal.move_word_forward.as_slice()), - ("vim_normal", "move_word_backward") => Some(runtime_keymap.vim_normal.move_word_backward.as_slice()), - ("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()), - ("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()), - ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), - ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), - ("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()), - ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), - ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), - ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), - ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), - ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), - ("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()), - ("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()), - ("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()), - ("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()), - ("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()), - ("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()), - ("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()), - ("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()), - ("vim_operator", "motion_down") => Some(runtime_keymap.vim_operator.motion_down.as_slice()), - ("vim_operator", "motion_word_forward") => Some(runtime_keymap.vim_operator.motion_word_forward.as_slice()), - ("vim_operator", "motion_word_backward") => Some(runtime_keymap.vim_operator.motion_word_backward.as_slice()), - ("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()), - ("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()), - ("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()), - ("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()), - ("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()), - ("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()), - ("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()), - ("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()), - ("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()), - ("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()), - ("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()), - ("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()), - ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), - ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), - ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), - ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), - ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), - ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), - ("pager", "page_down") => Some(runtime_keymap.pager.page_down.as_slice()), - ("pager", "half_page_up") => Some(runtime_keymap.pager.half_page_up.as_slice()), - ("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()), - ("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()), - ("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()), - ("pager", "close") => Some(runtime_keymap.pager.close.as_slice()), - ("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()), - ("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()), - ("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()), - ("list", "move_left") => Some(runtime_keymap.list.move_left.as_slice()), - ("list", "move_right") => Some(runtime_keymap.list.move_right.as_slice()), - ("list", "page_up") => Some(runtime_keymap.list.page_up.as_slice()), - ("list", "page_down") => Some(runtime_keymap.list.page_down.as_slice()), - ("list", "jump_top") => Some(runtime_keymap.list.jump_top.as_slice()), - ("list", "jump_bottom") => Some(runtime_keymap.list.jump_bottom.as_slice()), - ("list", "accept") => Some(runtime_keymap.list.accept.as_slice()), - ("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()), - ("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()), - ("approval", "open_thread") => Some(runtime_keymap.approval.open_thread.as_slice()), - ("approval", "approve") => Some(runtime_keymap.approval.approve.as_slice()), - ("approval", "approve_for_session") => Some(runtime_keymap.approval.approve_for_session.as_slice()), - ("approval", "approve_for_prefix") => Some(runtime_keymap.approval.approve_for_prefix.as_slice()), - ("approval", "deny") => Some(runtime_keymap.approval.deny.as_slice()), - ("approval", "decline") => Some(runtime_keymap.approval.decline.as_slice()), - ("approval", "cancel") => Some(runtime_keymap.approval.cancel.as_slice()), - _ => None, - } -} - -/// Format a resolved binding list for compact menu display. +/// Format an action's active single-key and chord alternatives in config order. /// /// Duplicate runtime variants that normalize to the same config spec are shown -/// once so compatibility defaults, such as alternate SHIFT reporting forms, do -/// not look like separate user choices. -pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String { +/// once so compatibility defaults do not appear as separate user choices. +pub(super) fn format_action_binding_summary( + runtime_keymap: &RuntimeKeymap, + context: &str, + action: &str, +) -> String { + let specs = super::active_binding_specs(runtime_keymap, context, action).unwrap_or_else(|_| { + bindings_for_action(runtime_keymap, context, action) + .unwrap_or_default() + .iter() + .filter_map(|binding| super::binding_to_config_key_spec(*binding).ok()) + .collect() + }); let mut seen = BTreeSet::new(); - let specs = bindings - .iter() - .filter_map(|binding| super::binding_to_config_key_spec(*binding).ok()) + let specs = specs + .into_iter() .filter(|spec| seen.insert(spec.clone())) .collect::>(); if specs.is_empty() { diff --git a/codex-rs/tui/src/keymap_setup/capture.rs b/codex-rs/tui/src/keymap_setup/capture.rs new file mode 100644 index 0000000000..94a70bc2a0 --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/capture.rs @@ -0,0 +1,174 @@ +//! Transient single-key and chord capture for the `/keymap` shortcut editor. + +use super::key_event_to_config_key_spec; +use crate::app_event::AppEvent; +use crate::app_event::KeymapCaptureMode; +use crate::app_event::KeymapEditIntent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::render::renderable::Renderable; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +/// Bottom-pane view that captures one key or a two-stroke `/keymap` chord. +/// +/// The view is deliberately transient: it renders instructions, captures the +/// requested number of strokes, and emits their canonical config representation +/// to the app layer. It does not mutate config itself, because mutation needs +/// the latest runtime keymap to detect conflicts and stale selections. +pub(crate) struct KeymapCaptureView { + context: String, + action: String, + intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, + first_stroke: Option, + label: String, + current_binding: String, + app_event_tx: AppEventSender, + complete: bool, + error_message: Option, +} + +impl KeymapCaptureView { + pub(super) fn new( + context: String, + action: String, + intent: KeymapEditIntent, + capture_mode: KeymapCaptureMode, + label: String, + current_binding: String, + app_event_tx: AppEventSender, + ) -> Self { + Self { + context, + action, + intent, + capture_mode, + first_stroke: None, + label, + current_binding, + app_event_tx, + complete: false, + error_message: None, + } + } + + fn lines(&self, width: u16) -> Vec> { + let wrap_width = usize::from(width.max(1)); + let mut lines = vec![ + Line::from("Remap Shortcut".bold()), + Line::from(vec![ + "Action: ".dim(), + self.label.clone().into(), + " ".into(), + format!("{}.{}", self.context, self.action).dim(), + ]), + Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]), + ]; + + let instructions = match (self.capture_mode, self.first_stroke.as_deref()) { + (KeymapCaptureMode::SingleKey, _) => "Press the new key now. Esc cancels.".to_string(), + (KeymapCaptureMode::Chord, None) => { + "Press the first key, then the second. Esc cancels.".to_string() + } + (KeymapCaptureMode::Chord, Some(first)) => { + format!("First key: {first}. Press the second key. Esc cancels.") + } + }; + lines.extend( + textwrap::wrap(&instructions, wrap_width) + .into_iter() + .map(|line| Line::from(line.into_owned().dim())), + ); + + if let Some(error) = &self.error_message { + lines.push(Line::from("")); + let options = textwrap::Options::new(wrap_width) + .initial_indent("Error: ") + .subsequent_indent(" "); + lines.extend( + textwrap::wrap(error, options) + .into_iter() + .map(|line| Line::from(line.into_owned().red())), + ); + } + + lines + } +} + +impl Renderable for KeymapCaptureView { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.lines(area.width)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +impl BottomPaneView for KeymapCaptureView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release + || (self.capture_mode == KeymapCaptureMode::Chord + && key_event.kind != KeyEventKind::Press) + { + return; + } + + if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) { + self.complete = true; + return; + } + + match key_event_to_config_key_spec(key_event) { + Ok(key) => { + self.error_message = None; + let key = if self.capture_mode == KeymapCaptureMode::Chord { + let Some(first_stroke) = self.first_stroke.take() else { + self.first_stroke = Some(key); + return; + }; + format!("{first_stroke} {key}") + } else { + key + }; + self.app_event_tx.send(AppEvent::KeymapCaptured { + context: self.context.clone(), + action: self.action.clone(), + key, + intent: self.intent.clone(), + }); + self.complete = true; + } + Err(error) => { + self.error_message = Some(error); + } + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +#[cfg(test)] +#[path = "capture_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/keymap_setup/capture_tests.rs b/codex-rs/tui/src/keymap_setup/capture_tests.rs new file mode 100644 index 0000000000..f8af0450f3 --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/capture_tests.rs @@ -0,0 +1,242 @@ +use super::super::KeymapEditOutcome; +use super::super::build_keymap_replace_binding_menu_params; +use super::super::keymap_with_bindings; +use super::super::keymap_with_edit; +use super::KeymapCaptureView; +use crate::app_event::AppEvent; +use crate::app_event::KeymapCaptureMode; +use crate::app_event::KeymapEditIntent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; + +fn capture_view() -> (KeymapCaptureView, UnboundedReceiver) { + let (tx, rx) = unbounded_channel(); + let view = KeymapCaptureView::new( + "list".to_string(), + "jump_top".to_string(), + KeymapEditIntent::AddAlternate, + KeymapCaptureMode::Chord, + "Jump Top".to_string(), + "home".to_string(), + AppEventSender::new(tx), + ); + (view, rx) +} + +fn ctrl_key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} + +fn capture_lines_at_width(view: &KeymapCaptureView, width: u16) -> String { + view.lines(width) + .into_iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn chord_capture_instruction_snapshots() { + let (mut view, _rx) = capture_view(); + + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 80), @r" + Remap Shortcut + Action: Jump Top list.jump_top + Current: home + Press the first key, then the second. Esc cancels. + "); + + view.handle_key_event(ctrl_key(KeyCode::Char('x'))); + + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 80), @r" + Remap Shortcut + Action: Jump Top list.jump_top + Current: home + First key: ctrl-x. Press the second key. Esc cancels. + "); +} + +#[test] +fn chord_capture_instructions_wrap_to_narrow_panes() { + let (mut view, _rx) = capture_view(); + + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r" + Remap Shortcut + Action: Jump Top list.jump_top + Current: home + Press the first key, + then the second. Esc + cancels. + "); + assert_eq!(view.desired_height(/*width*/ 24), 6); + + view.handle_key_event(KeyEvent::new( + KeyCode::F(/*n*/ 24), + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + )); + + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r" + Remap Shortcut + Action: Jump Top list.jump_top + Current: home + First key: ctrl-alt- + shift-f24. Press the + second key. Esc cancels. + "); + assert_eq!(view.desired_height(/*width*/ 24), 6); +} + +#[test] +fn captures_repeated_strokes_but_ignores_auto_repeat() { + let (mut view, mut rx) = capture_view(); + + view.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 25))); + assert!(view.error_message.is_some()); + view.handle_key_event(ctrl_key(KeyCode::Char('x'))); + assert_eq!(view.error_message, None); + view.handle_key_event(KeyEvent { + kind: KeyEventKind::Repeat, + ..ctrl_key(KeyCode::Char('x')) + }); + + assert!(!view.is_complete()); + assert_eq!(view.first_stroke.as_deref(), Some("ctrl-x")); + assert!(rx.try_recv().is_err()); + + view.handle_key_event(ctrl_key(KeyCode::Char('x'))); + + let AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } = rx.try_recv().expect("completed chord capture event") + else { + panic!("expected a captured key chord"); + }; + assert_eq!(context, "list"); + assert_eq!(action, "jump_top"); + assert_eq!(key, "ctrl-x ctrl-x"); + assert_eq!(intent, KeymapEditIntent::AddAlternate); + assert!(view.is_complete()); +} + +#[test] +fn modified_escape_is_captured_as_a_chord_stroke() { + let (mut canceled_view, mut canceled_events) = capture_view(); + canceled_view.handle_key_event(ctrl_key(KeyCode::Char('x'))); + canceled_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(canceled_view.is_complete()); + assert!(canceled_events.try_recv().is_err()); + + let ctrl_x = ctrl_key(KeyCode::Char('x')); + let ctrl_home = ctrl_key(KeyCode::Home); + let ctrl_esc = ctrl_key(KeyCode::Esc); + let alt_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::ALT); + for (first, second, expected) in [ + (ctrl_x, ctrl_esc, "ctrl-x ctrl-esc"), + (ctrl_x, alt_esc, "ctrl-x alt-esc"), + (ctrl_esc, ctrl_home, "ctrl-esc ctrl-home"), + (alt_esc, ctrl_home, "alt-esc ctrl-home"), + ] { + let (mut view, mut rx) = capture_view(); + view.handle_key_event(first); + assert!( + !view.is_complete(), + "the prefix of `{expected}` must be captured" + ); + assert!(rx.try_recv().is_err()); + + view.handle_key_event(second); + + let AppEvent::KeymapCaptured { key, .. } = + rx.try_recv().expect("completed modified-Escape chord") + else { + panic!("expected a captured key chord"); + }; + assert_eq!(key, expected); + assert!(view.is_complete()); + } +} + +#[test] +fn replacing_one_list_binding_with_chord_preserves_other_alternatives() { + let config = keymap_with_bindings( + &TuiKeymap::default(), + "list", + "jump_top", + &["home".to_string(), "ctrl-x home".to_string()], + ) + .expect("list chord bindings"); + let runtime = RuntimeKeymap::from_config(&config).expect("valid list key chords"); + let params = build_keymap_replace_binding_menu_params( + "list".to_string(), + "jump_top".to_string(), + &runtime, + ); + + let chord_item = params + .items + .iter() + .find(|item| item.name == "home (key chord)") + .expect("replace-one chord capture option"); + let (tx, mut rx) = unbounded_channel(); + let sender = AppEventSender::new(tx); + + chord_item.actions[0](&sender); + + let AppEvent::OpenKeymapCapture { + context, + action, + intent, + capture_mode, + } = rx.try_recv().expect("open replace-one chord capture") + else { + panic!("expected a chord capture event"); + }; + assert_eq!(capture_mode, KeymapCaptureMode::Chord); + assert_eq!( + intent, + KeymapEditIntent::ReplaceOne { + old_key: "home".to_string() + } + ); + + let outcome = keymap_with_edit( + &config, + &runtime, + &context, + &action, + "ctrl-x ctrl-h", + &intent, + ) + .expect("replace one binding with a key chord"); + + let expected_bindings = vec!["ctrl-x ctrl-h".to_string(), "ctrl-x home".to_string()]; + let expected_keymap = keymap_with_bindings(&config, "list", "jump_top", &expected_bindings) + .expect("updated list key chords"); + RuntimeKeymap::from_config(&expected_keymap).expect("updated list chord keymap is valid"); + assert_eq!( + outcome, + KeymapEditOutcome::Updated { + keymap_config: Box::new(expected_keymap), + bindings: expected_bindings, + message: "Replaced `home` with `ctrl-x ctrl-h` for `list.jump_top`.".to_string(), + } + ); +} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 883f9f898f..b062b865cc 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -21,8 +21,7 @@ use crate::style::accent_style; use super::actions::KEYMAP_ACTIONS; use super::actions::KeymapActionFilter; use super::actions::action_label; -use super::actions::bindings_for_action; -use super::actions::format_binding_summary; +use super::actions::format_action_binding_summary; use super::has_custom_binding; pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker"; @@ -335,24 +334,23 @@ fn build_keymap_rows( .iter() .copied() .filter(|descriptor| descriptor.is_visible(action_filter)) - .map(|descriptor| { - let bindings = - bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) - .unwrap_or(&[]); - KeymapActionRow { - context: descriptor.context, - context_label: descriptor.context_label, - action: descriptor.action, - label: action_label(descriptor.action), - description: descriptor.description, - binding_summary: format_binding_summary(bindings), - custom_binding: has_custom_binding( - keymap_config, - descriptor.context, - descriptor.action, - ) - .unwrap_or(false), - } + .map(|descriptor| KeymapActionRow { + context: descriptor.context, + context_label: descriptor.context_label, + action: descriptor.action, + label: action_label(descriptor.action), + description: descriptor.description, + binding_summary: format_action_binding_summary( + runtime_keymap, + descriptor.context, + descriptor.action, + ), + custom_binding: has_custom_binding( + keymap_config, + descriptor.context, + descriptor.action, + ) + .unwrap_or(/*default*/ false), }) .collect() } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b29fe1e760..f149bd0744 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -24,6 +24,7 @@ use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; use crate::keymap::PagerKeymap; use crate::render::Insets; use crate::render::renderable::InsetRenderable; @@ -90,12 +91,16 @@ impl Overlay { } } -fn first_or_empty(bindings: &[KeyBinding]) -> Vec { - bindings.first().copied().into_iter().collect() +fn first_or_empty( + keymap: &PagerKeymap, + action: &'static str, + bindings: &[KeyBinding], +) -> Vec { + keymap.primary_hint(action, bindings).into_iter().collect() } // Render a single line of key hints from (key(s), description) pairs. -fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { +fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { let mut spans: Vec> = vec![" ".into()]; let mut first = true; for (keys, desc) in pairs { @@ -106,7 +111,7 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &st if i > 0 { spans.push("/".into()); } - spans.push(Span::from(key)); + spans.push(Span::from(*key)); } spans.push(" ".into()); spans.push(Span::from(desc.to_string())); @@ -115,6 +120,31 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &st Paragraph::new(vec![Line::from(spans).dim()]).render(area, buf); } +fn render_navigation_hints(area: Rect, buf: &mut Buffer, keymap: &PagerKeymap) { + let actions = [ + ("scroll_up", &keymap.scroll_up), + ("scroll_down", &keymap.scroll_down), + ("page_up", &keymap.page_up), + ("page_down", &keymap.page_down), + ("jump_top", &keymap.jump_top), + ("jump_bottom", &keymap.jump_bottom), + ]; + let hints = actions + .chunks_exact(2) + .zip(["to scroll", "to page", "to jump"]) + .map(|(actions, description)| { + ( + actions + .iter() + .filter_map(|(action, bindings)| keymap.primary_hint(action, bindings)) + .collect(), + description, + ) + }) + .collect::>(); + render_key_hints(area, buf, &hints); +} + /// Generic widget for rendering a pager view. struct PagerView { renderables: Vec>, @@ -732,48 +762,27 @@ impl TranscriptOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints( - line1, - buf, - &[ - ( - first_or_empty(&self.view.keymap.scroll_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.scroll_down)) - .collect(), - "to scroll", - ), - ( - first_or_empty(&self.view.keymap.page_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.page_down)) - .collect(), - "to page", - ), - ( - first_or_empty(&self.view.keymap.jump_top) - .into_iter() - .chain(first_or_empty(&self.view.keymap.jump_bottom)) - .collect(), - "to jump", - ), - ], - ); + render_navigation_hints(line1, buf, &self.view.keymap); - let mut pairs: Vec<(Vec, &str)> = - vec![(first_or_empty(&self.view.keymap.close), "to quit")]; + let mut pairs: Vec<(Vec, &str)> = vec![( + first_or_empty(&self.view.keymap, "close", &self.view.keymap.close), + "to quit", + )]; if self.highlight_cell.is_some() { pairs.push(( vec![ - key_hint::plain(KeyCode::Esc), - key_hint::plain(KeyCode::Left), + key_hint::plain(KeyCode::Esc).into(), + key_hint::plain(KeyCode::Left).into(), ], "to edit prev", )); - pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next")); - pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message")); + pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next")); + pairs.push(( + vec![key_hint::plain(KeyCode::Enter).into()], + "to edit message", + )); } else { - pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev")); + pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -846,35 +855,11 @@ impl StaticOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints( - line1, - buf, - &[ - ( - first_or_empty(&self.view.keymap.scroll_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.scroll_down)) - .collect(), - "to scroll", - ), - ( - first_or_empty(&self.view.keymap.page_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.page_down)) - .collect(), - "to page", - ), - ( - first_or_empty(&self.view.keymap.jump_top) - .into_iter() - .chain(first_or_empty(&self.view.keymap.jump_bottom)) - .collect(), - "to jump", - ), - ], - ); - let pairs: Vec<(Vec, &str)> = - vec![(first_or_empty(&self.view.keymap.close), "to quit")]; + render_navigation_hints(line1, buf, &self.view.keymap); + let pairs: Vec<(Vec, &str)> = vec![( + first_or_empty(&self.view.keymap, "close", &self.view.keymap.close), + "to quit", + )]; render_key_hints(line2, buf, &pairs); } @@ -1035,6 +1020,27 @@ mod tests { ) } + #[test] + fn footer_hints_display_chords_without_internal_dispatch_keys() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + + let mut config = TuiKeymap::default(); + config.pager.page_up = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x page-up".to_string(), + ))); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("valid pager chord"); + + assert_eq!( + first_or_empty(&keymap.pager, "page_up", &keymap.pager.page_up), + vec![ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::PageUp), + }] + ); + } + #[test] fn edit_prev_hint_is_visible() { let mut overlay = transcript_overlay(vec![Arc::new(TestCell { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 2203929988..00f69e342f 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -12,8 +12,10 @@ use crate::git_action_directives::parse_assistant_markdown; use crate::inline_visualization::InlineVisualizationContext; use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::keymap::PagerKeymap; +use crate::keymap::RuntimeChordKeymap; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::legacy_core::config::edit::ConfigEditsBuilder; @@ -292,6 +294,7 @@ struct SessionPickerRunOptions { pager_keymap: PagerKeymap, list_keymap: ListKeymap, initial_page_mode: PageLoadMode, + chord_keymap: Arc, } /// Interactive session picker that lists app-server threads with simple search, @@ -383,6 +386,7 @@ async fn run_resume_picker_with_launch_context( } else { PageLoadMode::StateDbOnly }, + chord_keymap: runtime_keymap.chords, }; run_session_picker_with_loader( tui, @@ -434,6 +438,7 @@ pub async fn run_fork_picker_with_app_server( } else { PageLoadMode::StateDbOnly }, + chord_keymap: runtime_keymap.chords, }; run_session_picker_with_loader( tui, @@ -470,6 +475,7 @@ async fn run_session_picker_with_loader( state.view_persistence = options.view_persistence; state.pager_keymap = options.pager_keymap; state.list_keymap = options.list_keymap; + state.chord_keymap = options.chord_keymap; state.launch_context = options.launch_context; state.initial_page_mode = options.initial_page_mode; state.start_initial_load(); @@ -482,6 +488,14 @@ async fn run_session_picker_with_loader( tokio::select! { Some(ev) = tui_events.next() => { let screen_size = alt.tui.screen_size_for_event(&ev)?; + let ev = if let TuiEvent::Key(key) = ev { + let Some(key) = state.route_key_chord(key) else { + continue; + }; + TuiEvent::Key(key) + } else { + ev + }; if state.overlay.is_some() { state.handle_overlay_event(alt.tui, ev)?; continue; @@ -697,6 +711,8 @@ struct PickerState { pager_keymap: PagerKeymap, list_keymap: ListKeymap, initial_page_mode: PageLoadMode, + chord_keymap: Arc, + chord_matcher: crate::keymap::KeyChordMatcher, } #[derive(Clone, Copy, Debug)] @@ -967,6 +983,28 @@ impl PickerState { pager_keymap: RuntimeKeymap::defaults().pager, list_keymap: RuntimeKeymap::defaults().list, initial_page_mode: PageLoadMode::StoreDefault, + chord_keymap: Arc::default(), + chord_matcher: crate::keymap::KeyChordMatcher::default(), + } + } + + fn route_key_chord(&mut self, key: KeyEvent) -> Option { + let context = if self.overlay.is_some() { + crate::keymap::KeymapContext::Pager + } else { + crate::keymap::KeymapContext::List + }; + match self.chord_matcher.advance( + key, + &self.chord_keymap, + crate::keymap::KeymapContextSet::new(context), + tokio::time::Instant::now(), + ) { + crate::keymap::KeyChordMatch::PassThrough => Some(key), + crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event), + crate::keymap::KeyChordMatch::Pending(_) + | crate::keymap::KeyChordMatch::Cancelled + | crate::keymap::KeyChordMatch::Ignored => None, } } @@ -2052,7 +2090,7 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str { } struct PickerFooterHint { - key: &'static str, + key: String, wide_label: String, compact_label: String, priority: u8, @@ -2176,13 +2214,13 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { if state.is_transcript_loading() { let hints = [ PickerFooterHint { - key: "loading", + key: "loading".to_string(), wide_label: String::from("transcript"), compact_label: String::from("transcript"), priority: 0, }, PickerFooterHint { - key: "ctrl+c", + key: "ctrl+c".to_string(), wide_label: String::from("quit"), compact_label: String::from("quit"), priority: 1, @@ -2216,64 +2254,85 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { SessionListDensity::Comfortable => "dense", SessionListDensity::Dense => "comfy", }; - let first_row_hints = vec![ - PickerFooterHint { - key: "enter", + let mut first_row_hints = Vec::new(); + if let Some(accept) = state.list_keymap.primary_hint(ListAction::Accept) { + first_row_hints.push(PickerFooterHint { + key: accept.display_label(), wide_label: action_label.to_string(), compact_label: action_label.to_string(), priority: 0, - }, - PickerFooterHint { - key: "esc", + }); + } + if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) { + first_row_hints.push(PickerFooterHint { + key: cancel.display_label(), wide_label: esc_label.to_string(), compact_label: esc_compact_label.to_string(), priority: 1, - }, + }); + } + first_row_hints.extend([ PickerFooterHint { - key: "ctrl+c", + key: "ctrl+c".to_string(), wide_label: ctrl_c_label.to_string(), compact_label: ctrl_c_label.to_string(), priority: 2, }, PickerFooterHint { - key: "tab", + key: "tab".to_string(), wide_label: String::from("focus sort/filter"), compact_label: String::from("focus"), priority: 7, }, - PickerFooterHint { - key: "←/→", + ]); + let option_keys = [ListAction::MoveLeft, ListAction::MoveRight] + .into_iter() + .filter_map(|action| state.list_keymap.primary_hint(action)) + .map(super::key_hint::ShortcutHint::display_label) + .collect::>() + .join("/"); + if !option_keys.is_empty() { + first_row_hints.push(PickerFooterHint { + key: option_keys, wide_label: String::from("change option"), compact_label: String::from("option"), priority: 8, - }, - ]; - let second_row_hints = vec![ + }); + } + let mut second_row_hints = vec![ PickerFooterHint { - key: "ctrl+o", + key: "ctrl+o".to_string(), wide_label: density_label.to_string(), compact_label: density_compact_label.to_string(), priority: 3, }, PickerFooterHint { - key: "ctrl+t", + key: "ctrl+t".to_string(), wide_label: String::from("transcript"), compact_label: String::from("preview"), priority: 4, }, PickerFooterHint { - key: "ctrl+e", + key: "ctrl+e".to_string(), wide_label: String::from("expand"), compact_label: String::from("exp"), priority: 6, }, - PickerFooterHint { - key: "↑/↓", + ]; + let browse_keys = [ListAction::MoveUp, ListAction::MoveDown] + .into_iter() + .filter_map(|action| state.list_keymap.primary_hint(action)) + .map(super::key_hint::ShortcutHint::display_label) + .collect::>() + .join("/"); + if !browse_keys.is_empty() { + second_row_hints.push(PickerFooterHint { + key: browse_keys, wide_label: String::from("browse"), compact_label: String::from("browse"), priority: 5, - }, - ]; + }); + } vec![ hint_line_for_row(&first_row_hints, width), @@ -2393,7 +2452,7 @@ fn fit_footer_hint_refs( if idx > 0 { spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); } - spans.push(hint.key.set_style(footer_hint_key_style())); + spans.push(hint.key.clone().set_style(footer_hint_key_style())); let label = match mode { FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), @@ -2442,7 +2501,7 @@ fn footer_hints_width( } FooterHintLabelMode::KeyOnly => 0, }; - let hint_width = UnicodeWidthStr::width(hint.key) + label_width; + let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width; if idx == 0 { hint_width } else { @@ -4033,6 +4092,16 @@ mod tests { assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view")); assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript")); assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand")); + state.list_keymap.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))]; + state.list_keymap.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))]; + let remapped_footer = footer_lines_text(&state, /*width*/ 220); + assert!( + remapped_footer.contains("ctrl + h/ctrl + l change option"), + "{remapped_footer}" + ); + state.list_keymap.move_left.clear(); + state.list_keymap.move_right.clear(); + assert!(!footer_lines_text(&state, /*width*/ 220).contains("change option")); state.density = SessionListDensity::Dense; diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap index 516d7727af..4db68e2a91 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap @@ -4,12 +4,15 @@ expression: snapshot --- unbound: Set key | Capture a key for this unbound action. | enabled +Set key chord | Capture two consecutive keys for this action. | enabled Remove custom binding | Restore the default keymap binding. | enabled Back to shortcuts | Return to the shortcut list. | enabled single: Replace binding | Capture a replacement key. | enabled Add alternate binding | Keep the current binding and add another key. | enabled +Replace with key chord | Replace current bindings with a two-stroke key chord. | enabled +Add alternate key chord | Keep current bindings and add a two-stroke key chord. | enabled Remove custom binding | Restore the default keymap binding. | enabled Back to shortcuts | Return to the shortcut list. | enabled @@ -17,9 +20,13 @@ multi: Replace one binding... | Choose which existing binding to replace. | enabled Replace all bindings | Replace every current binding with one key. | enabled Add alternate binding | Keep current bindings and add another key. | enabled +Replace with key chord | Replace current bindings with a two-stroke key chord. | enabled +Add alternate key chord | Keep current bindings and add a two-stroke key chord. | enabled Remove custom binding | Restore the default keymap binding. | enabled Back to shortcuts | Return to the shortcut list. | enabled replace picker: ctrl-enter | Replace this binding. | enabled +ctrl-enter (key chord) | Replace this binding with a key chord. | enabled alt-shift-enter | Replace this binding. | enabled +alt-shift-enter (key chord) | Replace this binding with a key chord. | enabled diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap index 776375c264..fac6cd2d9a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap @@ -15,9 +15,15 @@ expression: snapshot 2. Add alternate binding Keep the current binding and add another key. + 3. Replace with key chord + Replace current bindings with a two- + stroke key chord. + 4. Add alternate key chord + Keep current bindings and add a two- + stroke key chord. – Remove custom binding (disabled) No custom root override to remove. - 3. Back to shortcuts + 5. Back to shortcuts Return to the shortcut list. Changes write the root `tui.keymap.*` @@ -36,9 +42,13 @@ expression: snapshot Capture one key and replace `ctrl-t`. 2. Add alternate binding Keep the current binding and add another key. + 3. Replace with key chord + Replace current bindings with a two-stroke key chord. + 4. Add alternate key chord + Keep current bindings and add a two-stroke key chord. – Remove custom binding (disabled) No custom root override to remove. - 3. Back to shortcuts + 5. Back to shortcuts Return to the shortcut list. Changes write the root `tui.keymap.*` override. @@ -54,8 +64,10 @@ expression: snapshot › 1. Replace binding Capture one key and replace `ctrl-t`. 2. Add alternate binding Keep the current binding and add another key. + 3. Replace with key chord Replace current bindings with a two-stroke key chord. + 4. Add alternate key chord Keep current bindings and add a two-stroke key chord. – Remove custom binding (disabled) No custom root override to remove. - 3. Back to shortcuts Return to the shortcut list. + 5. Back to shortcuts Return to the shortcut list. Changes write the root `tui.keymap.*` override. enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap new file mode 100644 index 0000000000..534fb4765d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 109 actions, 1 customized, 2 unbound. + + [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug + + Type to search shortcuts +› Global * Open Transcript ctrl-t, ctrl-x ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ + Chat Interrupt Turn esc + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 3cec4d0cb9..3c7d8ae661 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -98,6 +98,7 @@ async fn run_startup_hooks_review_app( app_event_tx.clone(), &keymap, ); + let mut chord_matcher = crate::keymap::KeyChordMatcher::default(); draw_view(tui, &view)?; let tui_events = tui.event_stream(); @@ -110,6 +111,18 @@ async fn run_startup_hooks_review_app( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => { + let key_event = match chord_matcher.advance( + key_event, + &keymap.chords, + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List), + tokio::time::Instant::now(), + ) { + crate::keymap::KeyChordMatch::PassThrough => key_event, + crate::keymap::KeyChordMatch::Completed(dispatch_event) => dispatch_event, + crate::keymap::KeyChordMatch::Pending(_) + | crate::keymap::KeyChordMatch::Cancelled + | crate::keymap::KeyChordMatch::Ignored => continue, + }; if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { view.handle_key_event(key_event); } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 7d705827c3..6e4de3bcab 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -20,7 +20,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event_sender::AppEventSender; use crate::key_hint; -use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; @@ -50,7 +50,7 @@ pub(crate) struct StatusIndicatorWidget { /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, - interrupt_binding: Option, + interrupt_binding: Option, elapsed_running: Duration, last_resume_at: Instant, @@ -89,7 +89,7 @@ impl StatusIndicatorWidget { details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, - interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()), elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, @@ -152,7 +152,7 @@ impl StatusIndicatorWidget { self.show_interrupt_hint = visible; } - pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { self.interrupt_binding = binding; } @@ -422,7 +422,7 @@ mod tests { crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, ); - w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into())); w.is_paused = true; w.elapsed_running = Duration::ZERO;