From a3f3b44cd8e11fc31c16fd805214786ca5425745 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 8 Oct 2026 20:40:47 +0000 Subject: [PATCH] Add configurable persistent leader shortcuts to the TUI (#52273) ## What changed - Add `tui.keymap.global.leader`, defaulting to `ctrl-x`, and symbolic bindings such as `leader c` that follow the configured prefix. The default prefix yields to existing custom shortcuts and chord prefixes. - Keep leader shortcut hints open until completion or cancellation, while ordinary chords retain their one-second timeout. Pressing `esc` or repeating the leader cancels the pending shortcut. - Add leader configuration and a `Use leader...` capture option to `/keymap`, with resolved shortcut labels and conflict validation. - Cancel pending chords when input targets change, on paste or focus loss, and on mouse actions other than movement. - Move the default voice mute shortcut from `ctrl-x` to `f9` and update its tests and displayed hints. GitOrigin-RevId: 35b964cccd2812c73200f0c26b0f19506821df92 --- codex-rs/config/src/tui_keymap.rs | 13 ++ codex-rs/core/config.schema.json | 10 ++ .../tests/suite/mcp_executor_context_tests.rs | 1 + codex-rs/ext/extension-api/tests/registry.rs | 2 + codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/agents_overview.rs | 24 +++- codex-rs/tui/src/app/input.rs | 6 + codex-rs/tui/src/app/tests/realtime_start.rs | 2 +- ...partial_completed_after_thread_switch.snap | 2 +- codex-rs/tui/src/app_event.rs | 3 +- .../src/bottom_pane/async_questions/state.rs | 7 +- codex-rs/tui/src/bottom_pane/mod.rs | 10 ++ .../tui/src/bottom_pane/voice_strip_tests.rs | 6 +- codex-rs/tui/src/chatwidget/interaction.rs | 4 + .../realtime/recording_controls_tests.rs | 12 +- ..._renders_the_main_conversation_states.snap | 10 +- codex-rs/tui/src/chatwidget/replay.rs | 6 +- ...sts__voice_live_transcript_and_stream.snap | 2 +- ...ests__voice_partial_transcript_hidden.snap | 2 +- codex-rs/tui/src/daemon_recovery.rs | 7 + codex-rs/tui/src/keymap.rs | 11 +- codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap/chords.rs | 136 ++++++++++++++++-- codex-rs/tui/src/keymap/leader.rs | 70 +++++++++ codex-rs/tui/src/keymap/voice_tests.rs | 8 +- codex-rs/tui/src/keymap_setup.rs | 42 +++++- codex-rs/tui/src/keymap_setup/actions.rs | 19 +++ codex-rs/tui/src/keymap_setup/capture.rs | 23 +-- codex-rs/tui/src/resume_picker.rs | 8 ++ codex-rs/tui/src/resume_picker/archive.rs | 2 + ...ymap_setup__tests__keymap_action_menu.snap | 3 + ..._tests__keymap_action_menu_responsive.snap | 23 +-- ...__tests__keymap_picker_all_tab_search.snap | 2 +- ...ap_setup__tests__keymap_picker_custom.snap | 6 +- ...ests__keymap_picker_fast_mode_enabled.snap | 6 +- ...p__tests__keymap_picker_first_actions.snap | 6 +- ...up__tests__keymap_picker_global_chord.snap | 6 +- ...ap_setup__tests__keymap_picker_narrow.snap | 6 +- ...ymap_setup__tests__keymap_picker_wide.snap | 6 +- ...map_setup__tests__keymap_voice_toggle.snap | 9 +- codex-rs/tui/src/startup_draft_input.rs | 7 +- codex-rs/tui/src/startup_hooks_review.rs | 5 + 42 files changed, 438 insertions(+), 100 deletions(-) create mode 100644 codex-rs/tui/src/keymap/leader.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 0caf64d5ea..47360c88c4 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -134,6 +134,8 @@ impl KeybindingsSpec { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiGlobalKeymap { + /// Prefix for persistent, context-aware shortcut menus. + pub leader: Option, /// Open the shared agent-session overview. pub open_agents: Option, /// Open the transcript overlay. @@ -594,6 +596,17 @@ fn normalize_keybinding_spec(raw: &str) -> Result { )); } + if strokes[0].eq_ignore_ascii_case("leader") { + let [_, completion] = strokes.as_slice() else { + return Err(format!( + "invalid keybinding `{raw}`: `leader` must be followed by one key \ +(for example `leader c`)." + )); + }; + return normalize_keybinding_stroke(completion) + .map(|completion| format!("leader {completion}")); + } + strokes .into_iter() .map(normalize_keybinding_stroke) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 80c955ba49..aba6689bd3 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4712,6 +4712,7 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "leader": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, @@ -5515,6 +5516,14 @@ ], "description": "Focus activity groups in the owned transcript to inspect their details." }, + "leader": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Prefix for persistent, context-aware shortcut menus." + }, "open_agents": { "allOf": [ { @@ -5710,6 +5719,7 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "leader": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, diff --git a/codex-rs/core/tests/suite/mcp_executor_context_tests.rs b/codex-rs/core/tests/suite/mcp_executor_context_tests.rs index fa5f079c3d..00440a4716 100644 --- a/codex-rs/core/tests/suite/mcp_executor_context_tests.rs +++ b/codex-rs/core/tests/suite/mcp_executor_context_tests.rs @@ -104,6 +104,7 @@ async fn thread_projection_preserves_executor_order_and_unavailable_selections() vec![unavailable.clone(), ready.clone()], vec![ready.clone(), unavailable.clone()], ] { + // These synthetic executor selections have no capability roots. let selections = requests .iter() .cloned() diff --git a/codex-rs/ext/extension-api/tests/registry.rs b/codex-rs/ext/extension-api/tests/registry.rs index a54c83108c..818ac8b80f 100644 --- a/codex-rs/ext/extension-api/tests/registry.rs +++ b/codex-rs/ext/extension-api/tests/registry.rs @@ -79,6 +79,7 @@ fn mcp_context_preserves_selected_executor_order_and_unavailability() { workspace_roots: Vec::new(), selected_capability_roots: Default::default(), config: EnvironmentConfigState::Pending, + selected_capability_roots: Default::default(), }, TurnEnvironmentSelection { environment_id: "ready-secondary".to_owned(), @@ -86,6 +87,7 @@ fn mcp_context_preserves_selected_executor_order_and_unavailability() { workspace_roots: Vec::new(), selected_capability_roots: Default::default(), config: EnvironmentConfigState::FromThread, + selected_capability_roots: Default::default(), }, ]; let context = McpServerContributionContext::global(&config); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 92ba41a3ce..9d7d85cd0d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -907,7 +907,9 @@ impl App { self.handle_draw_pre_render(tui, screen_size)?; } - if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) { + if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { self.cancel_pending_key_chord(); } diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index a62922385b..91baa6991e 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -21,6 +21,7 @@ use super::agents_overview_view::AgentsOverviewView; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::AgentsOverviewThreadRefresh; +use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -281,6 +282,11 @@ impl App { else { return; }; + let previous_thread_id = self + .agents_overview + .visible_thread_ids + .get(selected) + .copied(); let selected_thread_id = self .agents_overview .view_state @@ -292,12 +298,7 @@ impl App { && !self.agents_overview.hidden_threads.contains(id) }) .or(self.agents_overview.selection_after_removal.take()) - .or_else(|| { - self.agents_overview - .visible_thread_ids - .get(selected) - .copied() - }); + .or(previous_thread_id); let threads = self .agents_overview .threads @@ -307,6 +308,17 @@ impl App { .collect(); let view = self.agents_overview_view(threads, selected_thread_id); self.agents_overview.visible_thread_ids = view.thread_ids(); + let next_thread_id = view + .selected_index() + .and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied()); + if (previous_thread_id.is_none() || previous_thread_id != next_thread_id) + && self + .chat_widget + .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + self.cancel_pending_key_chord(); + } if let Ok(mut state) = self.agents_overview.view_state.lock() && state .rename_target diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 2c9247be8c..c1b0a76d9e 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -69,6 +69,9 @@ impl App { tui: &mut tui::Tui, key_event: KeyEvent, ) -> Option { + if self.chat_widget.take_key_chord_reset() { + self.cancel_pending_key_chord(); + } self.transcript_view.set_keymap_bindings(&self.keymap); if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { overlay.set_keymap_bindings(&self.keymap); @@ -174,6 +177,9 @@ impl App { } pub(super) fn expire_pending_key_chord(&mut self) { + if self.chat_widget.take_key_chord_reset() { + self.cancel_pending_key_chord(); + } let contexts = self.active_keymap_contexts(); if self.key_chord_matcher.expire(contexts) { self.set_key_chord_hint_override(/*items*/ None); diff --git a/codex-rs/tui/src/app/tests/realtime_start.rs b/codex-rs/tui/src/app/tests/realtime_start.rs index 6c75e0d572..295067c3b7 100644 --- a/codex-rs/tui/src/app/tests/realtime_start.rs +++ b/codex-rs/tui/src/app/tests/realtime_start.rs @@ -221,7 +221,7 @@ async fn voice_mute_shortcut_reaches_the_active_widget() -> Result<()> { app.handle_tui_event( &mut tui, &mut server, - tui::TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + tui::TuiEvent::Key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)), ) .await?; // The fixture has no microphone handle, so reaching mute reports that limitation. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap index 95d7bc67c5..bfd8b46a63 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap @@ -7,7 +7,7 @@ Other task: gpt-test default · /tmp/project Restored owner: - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁ › Ask Codex to do anything diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d63e43530a..7a388a223f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -240,11 +240,12 @@ pub(crate) enum KeymapEditIntent { ReplaceOne { old_key: String }, } -/// Number of key strokes recorded by one `/keymap` capture. +/// Kind of shortcut recorded by one `/keymap` capture. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum KeymapCaptureMode { SingleKey, Chord, + LeaderChord, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state.rs b/codex-rs/tui/src/bottom_pane/async_questions/state.rs index 96c4c2536b..d48fd99da7 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state.rs @@ -149,7 +149,8 @@ impl AsyncQuestions { } } - pub(crate) fn resolve_answers(&mut self, question_ids: &[String]) { + /// Return whether resolving these answers changed the expanded question's input target. + pub(crate) fn resolve_answers(&mut self, question_ids: &[String]) -> bool { // History can arrive before live questions or before restoring local drafts. self.state.answered_ids.extend(question_ids.iter().cloned()); // Older desktop replies identify the whole source message instead of one question. @@ -158,9 +159,10 @@ impl AsyncQuestions { || question_ids.contains(&question.message_id) }; if !self.state.pending.iter().any(answered) { - return; + return false; } let current_answered = self.current_answer().is_some_and(answered); + let displayed_answer_changed = self.expanded && current_answered; if current_answered { self.composer.flush_pending_input(); } @@ -183,6 +185,7 @@ impl AsyncQuestions { self.restore_current_draft(); self.composer.reset_vim_mode(); } + displayed_answer_changed } pub(crate) fn accept_answer(&mut self) { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 630df124e9..f81ddedd77 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,8 @@ //! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused //! interactions like selection lists. Centered views retain earlier views as a backdrop, //! while input remains routed exclusively to the top of the stack. +//! Modal insertion, replacement, and dismissal invalidate pending key chords, including when a +//! deferred prompt appears or an approval queue advances without changing keymap contexts. //! //! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs //! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent @@ -288,6 +290,7 @@ pub(crate) struct BottomPane { /// Stack of views displayed instead of the composer (e.g. popups/modals). view_stack: Vec>, + pub(crate) key_chord_reset_requested: bool, warnings_view: Option, /// A keep press can close the viewer; its remaining repeats must not edit the draft. pub(crate) suppress_warning_keep_repeat: bool, @@ -376,6 +379,7 @@ impl BottomPane { Self { composer, view_stack: Vec::new(), + key_chord_reset_requested: false, warnings_view: None, suppress_warning_keep_repeat: false, questions: None, @@ -715,6 +719,7 @@ impl BottomPane { } fn push_view(&mut self, view: Box) { + self.key_chord_reset_requested = true; self.view_stack.push(view); self.schedule_active_view_frame(); self.request_redraw(); @@ -744,6 +749,7 @@ impl BottomPane { } fn on_view_stack_depth_decreased(&mut self) { + self.key_chord_reset_requested = true; if self.view_stack.is_empty() { self.on_active_view_complete(); } @@ -1061,6 +1067,7 @@ impl BottomPane { self.on_active_view_complete(); } if needs_redraw || view_complete { + self.key_chord_reset_requested = true; self.request_redraw(); } } @@ -1510,6 +1517,7 @@ impl BottomPane { view.dismiss_after_child_accept = self.view_stack[index].dismiss_after_child_accept(); self.view_stack[index] = Box::new(view); if replaces_active_view { + self.key_chord_reset_requested = true; self.schedule_active_view_frame(); } self.request_redraw(); @@ -1618,6 +1626,7 @@ impl BottomPane { return false; } + self.key_chord_reset_requested = true; self.view_stack.pop(); self.request_redraw(); true @@ -1636,6 +1645,7 @@ impl BottomPane { let removed_active_view = index + 1 == self.view_stack.len(); self.view_stack.remove(index); if removed_active_view { + self.key_chord_reset_requested = true; self.schedule_active_view_frame(); } self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs b/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs index 0eeaca7fb4..208544c86f 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs @@ -55,7 +55,7 @@ fn active_dashboard_preserves_live_colors_controls_and_width() { assert!(actual.starts_with(" voice ")); assert!(actual.lines().nth(1).unwrap().starts_with(" mic ")); insta::assert_snapshot!(actual, @" - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ "); assert_eq!(buffer[(7, 0)].fg, Color::Red); @@ -63,7 +63,7 @@ fn active_dashboard_preserves_live_colors_controls_and_width() { assert_eq!(buffer[(22, 1)].fg, Color::Magenta); assert!(actual.contains("/voice stop")); let (compact, buffer) = rows(&strip, /*width*/ 37); - assert!(compact.contains("⌃x mute") && compact.contains("/voice stop")); + assert!(compact.contains("f9 mute") && compact.contains("/voice stop")); assert_eq!(buffer[(36, 0)].symbol(), " "); let (narrow, buffer) = rows(&strip, /*width*/ 22); assert!(narrow.contains("/voice stop")); @@ -89,7 +89,7 @@ fn connecting_and_muted_indicators_follow_actual_capture() { strip.state.animations = false; let (connecting, buffer) = rows(&strip, /*width*/ 45); assert!(connecting.contains("voice ◌ connecting")); - assert!(!connecting.contains("⌃x mute")); + assert!(!connecting.contains("f9 mute")); assert_ne!(buffer[(7, 0)].fg, Color::Red); strip.state.microphone_live = true; let (_, buffer) = rows(&strip, /*width*/ 45); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 5a8897ab91..7c25abd4e2 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -61,6 +61,10 @@ impl ChatWidget { self.bottom_pane.keymap_contexts() } + pub(crate) fn take_key_chord_reset(&mut self) -> bool { + std::mem::take(&mut self.bottom_pane.key_chord_reset_requested) + } + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventAction { if self.handle_startup_submission_key(key_event) { return KeyEventAction::None; diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs index cbe21174ae..f0efff94d2 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -72,7 +72,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() { .join("\n"); insta::assert_snapshot!(rows, @" 0: - 1: voice ● listening ⌃x mute /voice stop + 1: voice ● listening f9 mute /voice stop 2: mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁ 3: 4: › typed @@ -357,7 +357,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() { let footer = render_bottom_popup(&chat, /*width*/ 46); assert!(footer.contains("voice ● speaking")); - assert!(footer.contains("⌃x mute")); + assert!(footer.contains("f9 mute")); assert!(footer.contains("/voice stop")); chat.realtime_conversation.speaker_active_until = None; chat.realtime_conversation.speaker_level = 1; @@ -375,7 +375,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() { assert_eq!(chat.realtime_conversation.speaker_active_until, None); let interrupted = render_bottom_popup(&chat, /*width*/ 45); assert!(interrupted.contains("voice ● heard")); - assert!(interrupted.contains("⌃x mute")); + assert!(interrupted.contains("f9 mute")); assert!(interrupted.contains("/voice stop")); chat.realtime_conversation.interruption_acknowledged_until = Some(std::time::Instant::now() - super::super::INTERRUPTION_ACKNOWLEDGMENT); @@ -605,15 +605,15 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() { insta::assert_snapshot!(layouts.join("\n\n"), @" 5 rows: - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop › typed 6 rows: - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop › typed 8 rows: - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop › typed "); } diff --git a/codex-rs/tui/src/chatwidget/realtime/snapshots/codex_tui__chatwidget__realtime__tests__recording_controls_tests__voice_footer_renders_the_main_conversation_states.snap b/codex-rs/tui/src/chatwidget/realtime/snapshots/codex_tui__chatwidget__realtime__tests__recording_controls_tests__voice_footer_renders_the_main_conversation_states.snap index b1b63f41ee..8e26d02c69 100644 --- a/codex-rs/tui/src/chatwidget/realtime/snapshots/codex_tui__chatwidget__realtime__tests__recording_controls_tests__voice_footer_renders_the_main_conversation_states.snap +++ b/codex-rs/tui/src/chatwidget/realtime/snapshots/codex_tui__chatwidget__realtime__tests__recording_controls_tests__voice_footer_renders_the_main_conversation_states.snap @@ -10,7 +10,7 @@ connecting: status line stays visible listening: - voice ● listening ⌃x mute /voice stop + voice ● listening f9 mute /voice stop mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁ › Ask Codex to do anything @@ -18,7 +18,7 @@ listening: status line stays visible speaking: - voice ● speaking ⌃x mute /voice stop + voice ● speaking f9 mute /voice stop mic ▁▁▇▇▇▇ codex ▁▁████ › Ask Codex to do anything @@ -26,7 +26,7 @@ speaking: status line stays visible muted: - voice ◌ muted ⌃x unmute /voice stop + voice ◌ muted f9 unmute /voice stop mic ▁▁▁▁▁▁ codex ▁▁▇▇▇▇ › Ask Codex to do anything @@ -34,7 +34,7 @@ muted: status line stays visible transcript: - voice ● speaking ⌃x mute /voice stop + voice ● speaking f9 mute /voice stop mic ▁▁▄▄▄▄ codex ▁▁▄▄▄▄ › Ask Codex to do anything @@ -42,7 +42,7 @@ transcript: status line stays visible interrupted: - voice ● heard ⌃x mute /voice stop + voice ● heard f9 mute /voice stop mic ▁▁▄▄▄▄ codex ▁▁▄▄▄▄ › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 1907577c56..1ebff8fcb3 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -281,7 +281,11 @@ impl ChatWidget { .into_iter() .map(|reply| reply.question_item_id) .collect::>(); - self.bottom_pane.question_editor().resolve_answers(&ids); + let displayed_answer_changed = + self.bottom_pane.question_editor().resolve_answers(&ids); + if displayed_answer_changed && !self.bottom_pane.has_active_view() { + self.bottom_pane.key_chord_reset_requested = true; + } self.refresh_pending_input_preview(); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_live_transcript_and_stream.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_live_transcript_and_stream.snap index 8f6b9f294c..bf09f278df 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_live_transcript_and_stream.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_live_transcript_and_stream.snap @@ -10,7 +10,7 @@ expression: rendered " " " " " " -" voice ● listening ⌃x mute /voice stop " +" voice ● listening f9 mute /voice stop " " " "› Ask Codex to do anything " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_partial_transcript_hidden.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_partial_transcript_hidden.snap index 1fb5d6d6ad..bacf72efc6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_partial_transcript_hidden.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__voice_partial_transcript_hidden.snap @@ -6,7 +6,7 @@ expression: normalized_backend_snapshot(terminal.backend()) "• Agent answer arriving " " " " " -" voice ● listening ⌃x mute /voice stop " +" voice ● listening f9 mute /voice stop " " " "› Ask Codex to do anything " " " diff --git a/codex-rs/tui/src/daemon_recovery.rs b/codex-rs/tui/src/daemon_recovery.rs index 2c9959201e..071878f6eb 100644 --- a/codex-rs/tui/src/daemon_recovery.rs +++ b/codex-rs/tui/src/daemon_recovery.rs @@ -61,6 +61,13 @@ pub(super) async fn check( break None; }; tui.screen_size_for_event(&event)?; + if matches!( + &event, + crate::tui::TuiEvent::Paste(_) | crate::tui::TuiEvent::FocusLost + ) || matches!(&event, crate::tui::TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { + chord_matcher.cancel(); + } if let crate::tui::TuiEvent::Key(key) = event && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 047473ad51..8768364f08 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -34,6 +34,7 @@ use std::sync::Arc; mod bindings; mod chords; +mod leader; mod vim_search; pub(crate) use vim_search::VimSearchKeymap; @@ -646,13 +647,13 @@ impl RuntimeKeymap { || configured_context_alias_is_used(&keymap.approval, alias) }); - // Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default. + // Voice mute yields to existing shortcuts and chord prefixes. let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none() - && (configured_main_surface_alias_is_used(keymap, "ctrl-x") + && (configured_main_surface_alias_is_used(keymap, "f9") + || configured_context_alias_is_used(&keymap.vim_search, "f9") || chords.bindings.iter().any(|binding| { binding.action.context.overlaps(KeymapContext::Voice) - && binding.chord.prefix.parts() - == key_hint::ctrl(KeyCode::Char('x')).parts() + && binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(9)).parts() })); // New activity defaults yield to existing custom keys and chord prefixes. @@ -1662,7 +1663,7 @@ impl RuntimeKeymap { chords: Arc::default(), chat: ChatKeymap { toggle_voice: default_bindings![plain(KeyCode::F(8))], - toggle_voice_mute: default_bindings![ctrl(KeyCode::Char('x'))], + toggle_voice_mute: default_bindings![plain(KeyCode::F(9))], chord_hints: Arc::default(), interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![ diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 3e044f2437..08c7b0930b 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -211,6 +211,7 @@ macro_rules! define_runtime_action_bindings { action: &str, ) -> Option<&'a [KeyBinding]> { match (context, action) { + ("global", "leader") => Some(runtime_keymap.chords.leader.as_slice()), $( $( ($context, stringify!($action)) => { diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 0ad0f5e524..24adb25994 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -3,7 +3,9 @@ //! 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. -//! Pending chords expire after one second or when their active context changes. +//! Ordinary chords expire after one second. Shared leader prefixes stay pending until +//! completion or cancellation; both kinds cancel when their active context changes. +//! Symbolic leader expansion is capped before quadratic conflict validation. use super::MAIN_RESERVED_BINDINGS; use super::RuntimeKeymap; @@ -30,6 +32,7 @@ pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1; const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX; +const MAX_LEADER_CHORD_BINDINGS: usize = 1024; const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ("cancel", ctrl(KeyCode::Char('c'))), ("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))), @@ -199,9 +202,32 @@ pub(crate) struct RuntimeChordBinding { spec: String, } +impl RuntimeChordBinding { + fn is_leader(&self) -> bool { + self.spec.starts_with("leader ") + } + + /// Leader consumers also remain active beside pager actions on native transcript surfaces. + fn overlaps(&self, action: KeymapActionId) -> bool { + self.action.overlaps(action) + || self.is_leader() + && [ + KeymapContextSet::browsing(), + KeymapContextSet::new(KeymapContext::Global) + .with(KeymapContext::Chat) + .with_transcript_close(), + ] + .iter() + .any(|contexts| { + contexts.contains_action(self.action) && contexts.contains_action(action) + }) + } +} + /// Chord bindings plus configured alternatives in declaration order. #[derive(Clone, Debug, Default)] pub(crate) struct RuntimeChordKeymap { + pub(crate) leader: Vec, pub(crate) bindings: Vec, configured_specs: Vec<(KeymapActionId, Vec)>, } @@ -209,6 +235,7 @@ pub(crate) struct RuntimeChordKeymap { impl RuntimeChordKeymap { pub(super) fn from_config(keymap: &TuiKeymap) -> Result { let mut keymap_chords = Self::default(); + let mut leader_bindings = Vec::new(); for action in keymap_action_ids() { let Some(configured) = effective_configured_binding(keymap, action) else { continue; @@ -219,7 +246,22 @@ impl RuntimeChordKeymap { if configured_specs.iter().any(|configured| configured == raw) { continue; } - if let Some((prefix, completion)) = raw.split_once(' ') { + if let Some(completion) = raw.strip_prefix("leader ") { + let invalid_binding = || { + format!( + "Invalid `{}` = `{raw}`. Use `leader` followed by one key, such as \ +`leader c`.", + action.config_path() + ) + }; + leader_bindings.push(( + action, + normalize_chord_binding( + parse_keybinding(completion).ok_or_else(invalid_binding)?, + ), + raw.to_string(), + )); + } else if let Some((prefix, completion)) = raw.split_once(' ') { let invalid_binding = || { format!( "Invalid `{}` = `{raw}`. Use a single key such as `ctrl-a` \ @@ -282,6 +324,35 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.", spec: "g g".to_string(), }); } + super::leader::resolve(keymap, &mut keymap_chords)?; + if leader_bindings + .len() + .saturating_mul(keymap_chords.leader.len()) + > MAX_LEADER_CHORD_BINDINGS + { + return Err(format!( + "Too many leader shortcut combinations (maximum {MAX_LEADER_CHORD_BINDINGS}). \ +Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings." + )); + } + for (action, completion, spec) in leader_bindings { + for &prefix in &keymap_chords.leader { + let chord = KeyChord { prefix, completion }; + if let Some(existing) = keymap_chords + .bindings + .iter_mut() + .find(|existing| existing.action == action && existing.chord == chord) + { + existing.spec.clone_from(&spec); + } else { + keymap_chords.bindings.push(RuntimeChordBinding { + action, + chord, + spec: spec.clone(), + }); + } + } + } Ok(keymap_chords) } @@ -317,19 +388,24 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.", }) } - /// Return the user's first configured shortcut without exposing dispatch tokens. + /// Return the user's first active 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()) - { + if let Some(spec) = self.configured_specs(action).and_then(|specs| { + specs + .iter() + .find(|spec| !spec.starts_with("leader ") || !self.leader.is_empty()) + }) { return if let Some((prefix, completion)) = spec.split_once(' ') { Some(crate::key_hint::ShortcutHint::Chord { - prefix: parse_keybinding(prefix)?, + prefix: if prefix == "leader" { + *self.leader.first()? + } else { + parse_keybinding(prefix)? + }, completion: parse_keybinding(completion)?, }) } else { @@ -363,12 +439,13 @@ pub(crate) enum KeyChordMatch { #[derive(Clone, Copy, Debug)] struct PendingChord { - started_at: Instant, + // Persistent leader menus have no deadline. + started_at: Option, prefix: KeyBinding, contexts: KeymapContextSet, } -/// Tracks one pending two-stroke chord without buffering ordinary input. +/// Tracks one pending chord or leader menu without buffering ordinary input. #[derive(Clone, Debug, Default)] pub(crate) struct KeyChordMatcher { pending: Option, @@ -444,7 +521,10 @@ impl KeyChordMatcher { pub(crate) fn expire(&mut self, contexts: KeymapContextSet) -> bool { if self.pending.is_some_and(|pending| { - pending.contexts != contexts || pending.started_at.elapsed() >= KEY_CHORD_TIMEOUT + pending.contexts != contexts + || pending + .started_at + .is_some_and(|started_at| started_at.elapsed() >= KEY_CHORD_TIMEOUT) }) { self.pending = None; return true; @@ -472,7 +552,9 @@ impl KeyChordMatcher { } if let Some(pending) = self.pending.take() { - if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) { + if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) + || (pending.started_at.is_none() && chord_stroke_matches(pending.prefix, key_event)) + { return KeyChordMatch::Cancelled; } if let Some(binding) = @@ -494,8 +576,17 @@ impl KeyChordMatcher { }) .map(|binding| binding.chord.prefix) { + let persistent = keymap.bindings.iter().any(|binding| { + binding.chord.prefix == prefix + && contexts.contains_action(binding.action) + && (binding.is_leader() + || (keymap.leader.contains(&prefix) + && contexts.contains(KeymapContext::Global) + && !contexts.contains(KeymapContext::List) + && binding.action.context.overlaps(KeymapContext::Global))) + }); self.pending = Some(PendingChord { - started_at: Instant::now(), + started_at: (!persistent).then(Instant::now), prefix, contexts, }); @@ -604,9 +695,24 @@ pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), Str for (index, binding) in keymap.chords.bindings.iter().enumerate() { validate_binding_shape(binding)?; validate_reserved_strokes(binding)?; + if binding.chord.completion == binding.chord.prefix + && ((keymap.chords.leader.contains(&binding.chord.prefix) + && binding.action.context.overlaps(KeymapContext::Global)) + || keymap.chords.bindings.iter().any(|leader| { + leader.is_leader() + && leader.chord.prefix == binding.chord.prefix + && leader.overlaps(binding.action) + })) + { + return Err(format!( + "Ambiguous `{}` = `{}`: repeating the leader is reserved for cancellation.", + binding.action.config_path(), + binding.spec, + )); + } if let Some(conflict) = runtime_action_bindings(keymap) - .filter(|candidate| binding.action.overlaps(candidate.id)) + .filter(|candidate| binding.overlaps(candidate.id)) .find(|candidate| { candidate.bindings.iter().any(|single| { normalize_chord_binding(*single).parts() == binding.chord.prefix.parts() @@ -624,7 +730,7 @@ Unbind or remap the existing shortcut before using it as a chord prefix.", for previous in &keymap.chords.bindings[..index] { if previous.action != binding.action - && previous.action.overlaps(binding.action) + && (previous.overlaps(binding.action) || binding.overlaps(previous.action)) && previous.chord == binding.chord { return Err(format!( diff --git a/codex-rs/tui/src/keymap/leader.rs b/codex-rs/tui/src/keymap/leader.rs new file mode 100644 index 0000000000..6bbe0de5cf --- /dev/null +++ b/codex-rs/tui/src/keymap/leader.rs @@ -0,0 +1,70 @@ +//! Resolves and validates the shared shortcut prefix without assigning action bindings. +//! The default yields to explicit shortcuts; active consumers determine whether a menu opens. + +use super::MAIN_RESERVED_BINDINGS; +use super::chords::RuntimeChordKeymap; +use super::chords::normalize_chord_binding; +use super::configured_context_alias_is_used; +use super::resolve_bindings; +use crate::key_hint; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use itertools::Itertools; + +pub(super) fn resolve(config: &TuiKeymap, chords: &mut RuntimeChordKeymap) -> Result<(), String> { + let default = key_hint::ctrl(KeyCode::Char('x')); + let shadowed = config.global.leader.is_none() + && (configured_context_alias_is_used(config, "ctrl-x") + || chords + .bindings + .iter() + .any(|binding| binding.chord.prefix == default)); + if config.global.leader.as_ref().is_some_and(|bindings| { + bindings + .specs() + .iter() + .any(|spec| spec.as_str().contains(' ')) + }) { + return Err("Invalid `tui.keymap.global.leader`: use a single prefix key.".into()); + } + let defaults = [default]; + chords.leader = resolve_bindings( + config.global.leader.as_ref(), + if shadowed { &[] } else { &defaults }, + "tui.keymap.global.leader", + )? + .into_iter() + .map(normalize_chord_binding) + .unique() + .collect(); + + for prefix in &chords.leader { + let (key, modifiers) = prefix.parts(); + let label = prefix.display_label(); + if matches!(key, KeyCode::Char(_)) + && (!key_hint::has_ctrl_or_alt(modifiers) || key_hint::is_altgr(modifiers)) + { + return Err(format!( + "Invalid `tui.keymap.global.leader` = `{label}`: use ctrl, alt, or a non-character \ +key so ordinary text input is not intercepted. Ctrl-alt characters may be AltGr input on Windows." + )); + } + #[cfg(unix)] + if *prefix == key_hint::ctrl(KeyCode::Char('z')) { + return Err(format!( + "Invalid `tui.keymap.global.leader` = `{label}`: ctrl-z is reserved for suspending \ +the terminal on Unix." + )); + } + if let Some((reserved_action, _)) = MAIN_RESERVED_BINDINGS + .iter() + .find(|(_, reserved)| prefix.parts() == reserved.parts()) + { + return Err(format!( + "Invalid `tui.keymap.global.leader` = `{label}`: this key is reserved by \ +`{reserved_action}`." + )); + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/keymap/voice_tests.rs b/codex-rs/tui/src/keymap/voice_tests.rs index 4bbea95eb7..6e5bc12a3b 100644 --- a/codex-rs/tui/src/keymap/voice_tests.rs +++ b/codex-rs/tui/src/keymap/voice_tests.rs @@ -8,7 +8,7 @@ use pretty_assertions::assert_eq; #[test] fn voice_mute_resolves_custom_bindings_unbinding_and_visible_hints() { for (config, expected) in [ - ("", Some("⌃x")), + ("", Some("f9")), ("[chat]\ntoggle_voice_mute = 'f8'", Some("f8")), ("[chat]\ntoggle_voice_mute = []", None), ("[chat]\ntoggle_voice_mute = 'ctrl-x m'", Some("⌃x m")), @@ -27,9 +27,9 @@ fn voice_mute_resolves_custom_bindings_unbinding_and_visible_hints() { #[test] fn voice_mute_default_yields_to_existing_shortcuts_and_chord_prefixes() { for config in [ - "[editor]\nkill_line_end = 'ctrl-x'", - "[global]\ncopy = 'ctrl-x'", - "[global]\nopen_transcript = 'ctrl-x ctrl-t'", + "[editor]\nkill_line_end = 'f9'", + "[global]\ncopy = 'f9'", + "[global]\nopen_transcript = 'f9 ctrl-t'", ] { let config = toml::from_str::(config).unwrap(); let runtime = RuntimeKeymap::from_config(&config).expect("existing binding stays valid"); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 98c8dd7b3f..3aa04ae215 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -9,8 +9,8 @@ //! //! The flow is intentionally split into three steps: choose an action, choose //! 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 +//! key event, a bounded two-stroke chord, or the key following the configured +//! leader. Validation reuses 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 @@ -249,7 +249,35 @@ pub(crate) fn build_keymap_action_menu_params( )); } } - if active_binding_count == 0 { + if action != "leader" { + let leader_available = !runtime_keymap.chords.leader.is_empty(); + items.push(SelectionItem { + name: "Use leader...".to_string(), + description: Some("Capture the key that follows the configured leader".to_string()), + disabled_reason: (!leader_available).then(|| "The leader is unbound".to_string()), + actions: vec![open_capture_action( + context.clone(), + action.clone(), + KeymapEditIntent::ReplaceAll, + KeymapCaptureMode::LeaderChord, + )], + ..Default::default() + }); + if !leader_available { + items.push(SelectionItem { + name: "Configure leader...".to_string(), + description: Some("Set a leader before assigning this shortcut".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenKeymapActionMenu { + context: "global".to_string(), + action: "leader".to_string(), + }); + })], + ..Default::default() + }); + } + } + if action != "leader" && active_binding_count == 0 { items.push(action_menu_item( "Set key chord", "Capture two consecutive keys for this action".to_string(), @@ -258,7 +286,7 @@ pub(crate) fn build_keymap_action_menu_params( KeymapEditIntent::ReplaceAll, KeymapCaptureMode::Chord, )); - } else { + } else if action != "leader" { items.push(action_menu_item( "Replace with key chord", format!("Replace `{current_binding}` with a two-stroke key chord"), @@ -358,6 +386,8 @@ pub(crate) fn build_keymap_replace_binding_menu_params( ..Default::default() }, ] + .into_iter() + .take(if action == "leader" { 1 } else { 2 }) }) .collect(); @@ -378,7 +408,9 @@ pub(crate) fn build_keymap_conflict_params( error: String, runtime_keymap: &RuntimeKeymap, ) -> SelectionViewParams { - let capture_mode = if key.contains(' ') { + let capture_mode = if key.starts_with("leader ") { + KeymapCaptureMode::LeaderChord + } else if key.contains(' ') { KeymapCaptureMode::Chord } else { KeymapCaptureMode::SingleKey diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 46ec0ac847..3f9efbd357 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -97,6 +97,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), action("global", "Global", "toggle_side_conversation", "Switch between a side conversation and its parent."), + action("global", "Global", "leader", "Set the prefix used by shared leader shortcuts."), action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), @@ -272,6 +273,7 @@ pub(super) fn binding_slot<'a>( action: &str, ) -> Option<&'a mut Option> { match (context, action) { + ("global", "leader") => Some(&mut keymap.global.leader), ("global", "open_agents") => Some(&mut keymap.global.open_agents), ("global", "open_transcript") => Some(&mut keymap.global.open_transcript), ("global", "find_transcript") => Some(&mut keymap.global.find_transcript), @@ -448,6 +450,23 @@ pub(super) fn format_action_binding_summary( let specs = specs .into_iter() .filter(|spec| seen.insert(spec.clone())) + .map(|spec| { + let Some(completion) = spec.strip_prefix("leader ") else { + return spec; + }; + let resolved = runtime_keymap + .chords + .leader + .iter() + .filter_map(|prefix| super::binding_to_config_key_spec(*prefix).ok()) + .map(|prefix| format!("{prefix} {completion}")) + .collect::>(); + if resolved.is_empty() { + format!("{spec} (inactive)") + } else { + format!("{spec} ({})", resolved.join(", ")) + } + }) .collect::>(); if specs.is_empty() { "unbound".to_string() diff --git a/codex-rs/tui/src/keymap_setup/capture.rs b/codex-rs/tui/src/keymap_setup/capture.rs index a3409287dd..07689cfad7 100644 --- a/codex-rs/tui/src/keymap_setup/capture.rs +++ b/codex-rs/tui/src/keymap_setup/capture.rs @@ -1,4 +1,4 @@ -//! Transient single-key and chord capture inside the shared `/keymap` panel. +//! Captures single keys, literal chords, and leader suffixes inside `/keymap`. //! //! Content is measured at the inset width, including wrapped action identifiers. //! Raw key capture remains independent of list navigation bindings. @@ -94,6 +94,9 @@ impl KeymapCaptureView { (KeymapCaptureMode::Chord, Some(first)) => { format!("First key: {first}. Press the second key. Esc cancels.") } + (KeymapCaptureMode::LeaderChord, _) => { + "Press the key that follows the configured leader. Esc cancels.".to_string() + } }; lines.extend( textwrap::wrap(&instructions, wrap_width) @@ -150,14 +153,16 @@ impl BottomPaneView for KeymapCaptureView { 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 + let key = match self.capture_mode { + KeymapCaptureMode::SingleKey => key, + KeymapCaptureMode::LeaderChord => format!("leader {key}"), + KeymapCaptureMode::Chord => { + let Some(first_stroke) = self.first_stroke.take() else { + self.first_stroke = Some(key); + return; + }; + format!("{first_stroke} {key}") + } }; self.app_event_tx.send(AppEvent::KeymapCaptured { context: self.context.clone(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1142d40840..95ba485746 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -596,6 +596,11 @@ async fn run_session_picker_with_loader( Some(ev) = tui_events.next() => { alt.tui.clipboard.poll(); let screen_size = alt.tui.screen_size_for_event(&ev)?; + if matches!(&ev, TuiEvent::Paste(_) | TuiEvent::FocusLost) + || matches!(&ev, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { + state.chord_matcher.cancel(); + } let ev = if let TuiEvent::Key(key) = ev { let Some(key) = state.route_key_chord(key) else { continue; @@ -1156,6 +1161,7 @@ impl PickerState { if let Overlay::Transcript(view) = &mut overlay { view.set_keymap_bindings(&self.keymap); } + self.chord_matcher.cancel(); self.overlay = Some(overlay); self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; @@ -1576,6 +1582,7 @@ impl PickerState { self.transcript_cells .insert(thread_id, SessionTranscriptState::Failed); if self.pending_transcript_open == Some(thread_id) { + self.chord_matcher.cancel(); self.pending_transcript_cancellation = None; self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; @@ -1652,6 +1659,7 @@ impl PickerState { } fn apply_filter(&mut self) { + self.chord_matcher.cancel(); let base_iter = self .all_rows .iter() diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs index 875e74b192..a3ccf54312 100644 --- a/codex-rs/tui/src/resume_picker/archive.rs +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -83,6 +83,7 @@ impl PickerState { if self.archive_state != (ArchiveState::Pending { thread_id }) { return; } + self.chord_matcher.cancel(); self.archive_state = ArchiveState::Idle; if let Err(error) = result { @@ -154,6 +155,7 @@ impl PickerState { if self.archive_state != (ArchiveState::Restoring { thread_id }) { return None; } + self.chord_matcher.cancel(); self.archive_state = ArchiveState::Idle; match result { 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 4fc9794097..86f82648e2 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,6 +4,7 @@ expression: snapshot --- unbound: Set key | Capture a key for this unbound action | enabled +Use leader... | Capture the key that follows the configured leader | 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 @@ -11,6 +12,7 @@ Back to shortcuts | Return to the shortcut list | enabled single: Replace binding | Capture a replacement key for `ctrl-enter` | enabled Add alternate binding | Keep `ctrl-enter` and add another key | enabled +Use leader... | Capture the key that follows the configured leader | enabled Replace with key chord | Replace `ctrl-enter` with a two-stroke key chord | enabled Add alternate key chord | Keep `ctrl-enter` and add a two-stroke key chord | enabled Remove custom binding | Restore the default keymap binding | enabled @@ -20,6 +22,7 @@ multi: Replace one binding... | Choose which existing binding to replace | enabled Replace all bindings | Replace `ctrl-enter, alt-shift-enter` with one key | enabled Add alternate binding | Keep `ctrl-enter, alt-shift-enter` and add another key | enabled +Use leader... | Capture the key that follows the configured leader | enabled Replace with key chord | Replace `ctrl-enter, alt-shift-enter` with a two-stroke key chord | enabled Add alternate key chord | Keep `ctrl-enter, alt-shift-enter` and add a two-stroke key chord | enabled Remove custom binding | Restore the default keymap binding | 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 94b644aaa7..1b5b0773a0 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 @@ -13,10 +13,11 @@ expression: snapshot › 1. Replace binding 2. Add alternate binding - 3. Replace with key chord - 4. Add alternate key chord + 3. Use leader... + 4. Replace with key chord + 5. Add alternate key chord – Remove custom binding (disabled) - 5. Back to shortcuts + 6. Back to shortcuts Changes write the root `tui.keymap.*` override. @@ -35,13 +36,16 @@ expression: snapshot for `ctrl-t` 2. Add alternate binding Keep `ctrl-t` and add another key - 3. Replace with key chord Replace `ctrl-t` with a + 3. Use leader... Capture the key that + follows the configured + leader + 4. Replace with key chord Replace `ctrl-t` with a two-stroke key chord - 4. Add alternate key chord Keep `ctrl-t` and add a + 5. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord – Remove custom binding (disabled) No custom root override to remove - 5. Back to shortcuts Return to the shortcut + 6. Back to shortcuts Return to the shortcut list Changes write the root `tui.keymap.*` override. @@ -58,10 +62,11 @@ expression: snapshot › 1. Replace binding Capture a replacement key for `ctrl-t` 2. Add alternate binding Keep `ctrl-t` and add another key - 3. Replace with key chord Replace `ctrl-t` with a two-stroke key chord - 4. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord + 3. Use leader... Capture the key that follows the configured leader + 4. Replace with key chord Replace `ctrl-t` with a two-stroke key chord + 5. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord – Remove custom binding (disabled) No custom root override to remove - 5. Back to shortcuts Return to the shortcut list + 6. 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_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index a755a71364..4918c7bf55 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -12,5 +12,5 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default +Leader | ctrl-x | Global leader Leader Set the prefix used by shared leader shortcuts. ctrl-x Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default -Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 0e761ed368..ab1c5ac979 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 151 actions, 1 customized, 5 unbound. + 152 actions, 1 customized, 5 unbound. All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -21,6 +21,7 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Global Toggle Side Conversation ctrl-/ + Global Leader ctrl-x Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-,, shift-down Chat Increase Reasoning Effort alt-., shift-up @@ -30,10 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 - Chat Toggle Voice Mute ctrl-x + Chat Toggle Voice Mute f9 Composer * Submit ctrl-enter Composer Queue tab Composer Toggle Shortcuts ?, shift-? - Composer History Search Previous ctrl-r ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index ba5ba36700..87197e2c21 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 152 actions, 0 customized, 6 unbound. + 153 actions, 0 customized, 6 unbound. All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval › @@ -22,6 +22,7 @@ expression: "render_picker(params, 120)" Global - Toggle Fast Mode unbound Global Toggle Raw Output alt-r Global Toggle Side Conversation ctrl-/ + Global Leader ctrl-x Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-,, shift-down Chat Increase Reasoning Effort alt-., shift-up @@ -31,9 +32,8 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 - Chat Toggle Voice Mute ctrl-x + Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab - Composer Toggle Shortcuts ?, shift-? ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 19f0e027b3..f539bb578e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (151 selectable) +tab: All (152 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) -tab: App (20 selectable) +tab: App (21 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) @@ -24,6 +24,6 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default +Leader | ctrl-x | Global leader Leader Set the prefix used by shared leader shortcuts. ctrl-x Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default -Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default 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 index cc3222de83..e517985cb8 100644 --- 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 @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 151 actions, 1 customized, 6 unbound. + 152 actions, 1 customized, 6 unbound. All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval › @@ -21,6 +21,7 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Global Toggle Side Conversation ctrl-/ + Global - Leader unbound Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-,, shift-down Chat Increase Reasoning Effort alt-., shift-up @@ -30,10 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 - Chat - Toggle Voice Mute unbound + Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab Composer Toggle Shortcuts ?, shift-? - Composer History Search Previous ctrl-r ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 953b11dfff..0d353cc83b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 151 actions, 0 customized, 5 unbound. + 152 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor › @@ -21,6 +21,7 @@ expression: "render_picker(params, 78)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Global Toggle Side Conversation ctrl-/ + Global Leader ctrl-x Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-,, shift-down Chat Increase Reasoning Effort alt-., shift-up @@ -30,10 +31,9 @@ expression: "render_picker(params, 78)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 - Chat Toggle Voice Mute ctrl-x + Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab Composer Toggle Shortcuts ?, shift-? - Composer History Search Previous ctrl-r ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 2296ebc618..a7e7335399 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 151 actions, 0 customized, 5 unbound. + 152 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -21,6 +21,7 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Global Toggle Side Conversation ctrl-/ + Global Leader ctrl-x Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-,, shift-down Chat Increase Reasoning Effort alt-., shift-up @@ -30,10 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 - Chat Toggle Voice Mute ctrl-x + Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab Composer Toggle Shortcuts ?, shift-? - Composer History Search Previous ctrl-r ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap index a7ea24a588..341e0e55c1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap @@ -2,6 +2,7 @@ source: tui/src/keymap_setup.rs expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"toggle_voice\".into(), &RuntimeKeymap::defaults(), &TuiKeymap::default(),),\n80,)" --- + Edit Shortcut Toggle Voice · Chat Current f8 · Default keymap @@ -11,10 +12,12 @@ expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"t › 1. Replace binding Capture a replacement key for `f8` 2. Add alternate binding Keep `f8` and add another key - 3. Replace with key chord Replace `f8` with a two-stroke key chord - 4. Add alternate key chord Keep `f8` and add a two-stroke key chord + 3. Use leader... Capture the key that follows the + configured leader + 4. Replace with key chord Replace `f8` with a two-stroke key chord + 5. Add alternate key chord Keep `f8` and add a two-stroke key chord – Remove custom binding (disabled) No custom root override to remove - 5. Back to shortcuts Return to the shortcut list + 6. 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/startup_draft_input.rs b/codex-rs/tui/src/startup_draft_input.rs index 491b99c7fc..b37e70957e 100644 --- a/codex-rs/tui/src/startup_draft_input.rs +++ b/codex-rs/tui/src/startup_draft_input.rs @@ -23,6 +23,11 @@ use crate::tui::TuiEvent; impl StartupDraftPump { pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> { let screen_size = tui.screen_size_for_event(&event)?; + if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { + self.key_chord_matcher.cancel(); + } if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() { let continues_paste = match &event { TuiEvent::Key(KeyEvent { @@ -151,7 +156,6 @@ impl StartupDraftPump { } } TuiEvent::Paste(text) => { - self.key_chord_matcher.cancel(); if self.initial_screen == StartupDraftInitialScreen::Composer { self.cancel_submission(); self.bottom_pane.flush_composer_paste_burst(); @@ -160,7 +164,6 @@ impl StartupDraftPump { } TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} TuiEvent::FocusLost => { - self.key_chord_matcher.cancel(); self.blossom.borrow_mut().cancel_replay(); return Ok(()); } diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 7b39a4de46..6ad297e20c 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -113,6 +113,11 @@ async fn run_startup_hooks_review_app( return Ok(StartupHooksReviewOutcome::Continue); }; tui.screen_size_for_event(&event)?; + if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { + chord_matcher.cancel(); + } match event { TuiEvent::Key(key_event) => { let key_event = match chord_matcher.advance(