From 45305dd229c01e6cb6e122f6559e4f9b805bae6b Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Tue, 8 Sep 2026 03:53:28 +0000 Subject: [PATCH] Make the voice mute shortcut configurable in the TUI (#43690) ## Why Voice mute used a hard-coded `Ctrl+X` shortcut and footer hint, so keymap customization could not change or disable it. ## What changed - Wire `tui.keymap.chat.toggle_voice_mute` into runtime keybindings and the keymap picker, supporting custom keys, chords, and unbinding. - Keep `Ctrl+X` as the default, but let existing configured shortcuts and overlapping chord prefixes take precedence. Validate explicitly configured mute bindings for conflicts. - Activate voice chords only when the microphone shortcut is available, excluding pagers and overlays. - Refresh the voice footer when bindings change, showing the configured shortcut or `/voice mute` when unbound. ## Testing Add regression tests for binding resolution, conflicts, voice chord context, and live keymap updates. Add footer snapshots covering custom keys, chords, and unbinding in muted and listening states at wide and narrow widths. GitOrigin-RevId: ec9385f3956aa268bba34dcf2cc2b0c92a31bfec --- codex-rs/tui/src/app/input.rs | 20 +++-- ...configured_mute_binding_and_unbinding.snap | 52 ++++++++++++ codex-rs/tui/src/bottom_pane/voice_strip.rs | 32 ++++--- .../tui/src/bottom_pane/voice_strip_tests.rs | 34 ++++++++ codex-rs/tui/src/chatwidget/keymap_picker.rs | 1 + codex-rs/tui/src/chatwidget/realtime.rs | 4 +- .../chatwidget/realtime/recording_controls.rs | 27 +++--- .../realtime/recording_controls_tests.rs | 26 ++++++ codex-rs/tui/src/keymap.rs | 47 +++++++++- codex-rs/tui/src/keymap/bindings.rs | 14 ++- codex-rs/tui/src/keymap/chords.rs | 2 + codex-rs/tui/src/keymap/voice_tests.rs | 85 +++++++++++++++++++ codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- ...setup__tests__keymap_question_actions.snap | 2 +- 20 files changed, 319 insertions(+), 45 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap create mode 100644 codex-rs/tui/src/keymap/voice_tests.rs diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index fbf1d98032..3b7dccb4ba 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -121,15 +121,23 @@ impl App { } fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { - if self.overlay.is_some() { - return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Pager); - } + use crate::keymap::KeymapContext; + use crate::keymap::KeymapContextSet; + if self.overlay.is_some() { + return KeymapContextSet::new(KeymapContext::Pager); + } + let voice_available = self.chat_widget.realtime_microphone_shortcut_available(); 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) + let contexts = contexts + .with(KeymapContext::Global) + .with(KeymapContext::Chat); + if voice_available { + contexts.with(KeymapContext::Voice) + } else { + contexts + } } else { contexts } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap new file mode 100644 index 0000000000..5f1bfe8d19 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap @@ -0,0 +1,52 @@ +--- +source: tui/src/bottom_pane/voice_strip_tests.rs +assertion_line: 165 +expression: "layouts.join(\"\\n\\n\")" +--- +'f8', muted=false, width=80: + voice ● listening f8 mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +'f8', muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +'f8', muted=true, width=80: + voice ◌ muted f8 unmute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +'f8', muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ + +'ctrl-x m', muted=false, width=80: + voice ● listening ctrl+x m mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +'ctrl-x m', muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +'ctrl-x m', muted=true, width=80: + voice ◌ muted ctrl+x m unmute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +'ctrl-x m', muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ + +[], muted=false, width=80: + voice ● listening /voice mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +[], muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +[], muted=true, width=80: + voice ◌ muted /voice mute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +[], muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ diff --git a/codex-rs/tui/src/bottom_pane/voice_strip.rs b/codex-rs/tui/src/bottom_pane/voice_strip.rs index 5385c9a555..373ef6a049 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip.rs @@ -1,6 +1,7 @@ //! Renders compact voice controls and caller-owned microphone/speaker sample histories. //! Keep control positions stable and show mute only when the current phase permits it. +use crate::key_hint::ShortcutHint; use crate::motion::MotionMode; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; @@ -15,6 +16,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use std::time::Duration; use std::time::Instant; +use unicode_width::UnicodeWidthStr; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum VoiceStripPhase { @@ -24,6 +26,7 @@ pub(crate) enum VoiceStripPhase { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct VoiceStripState { + pub(crate) mute_hint: Option, pub(crate) phase: VoiceStripPhase, pub(crate) microphone_live: bool, pub(crate) microphone_muted: bool, @@ -97,30 +100,39 @@ impl Renderable for VoiceStrip { }; let activity = format!(" {}", self.state.activity).into(); let mut status = Line::from(vec!["voice ".dim(), marker, activity]); - let mute = if self.state.microphone_muted { - "ctrl+x unmute" - } else { - "ctrl+x mute " - }; + let mute = self.state.mute_hint.map_or_else( + || "/voice mute".to_string(), + |hint| { + format!( + "{} {}", + hint.display_label().replace(" + ", "+"), + if self.state.microphone_muted { + "unmute" + } else { + "mute " + } + ) + }, + ); let full_controls = format!("{mute} /voice stop"); let available = usize::from(area.width); let can_mute = !connecting || self.state.microphone_live || self.state.microphone_muted; if can_mute - && available < status.width() + full_controls.len() + 1 - && available >= "voice ".len() + full_controls.len() + 2 + && available < status.width() + full_controls.width() + 1 + && available >= "voice ".len() + full_controls.width() + 2 { status.spans.pop(); } - let controls = if can_mute && available > status.width() + full_controls.len() { + let controls = if can_mute && available > status.width() + full_controls.width() { full_controls.as_str() } else { "/voice stop" }; - if available < status.width() + controls.len() + 1 { + if available < status.width() + controls.width() + 1 { status = Line::from(vec!["voice".dim()]); } status.spans.push( - " ".repeat(available.saturating_sub(status.width() + controls.len())) + " ".repeat(available.saturating_sub(status.width() + controls.width())) .into(), ); status.spans.push(controls.dim()); 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 61cd4b8192..4a22988da0 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs @@ -8,6 +8,9 @@ use ratatui::style::Color; fn active_strip() -> VoiceStrip { VoiceStrip::new( VoiceStripState { + mute_hint: crate::keymap::RuntimeKeymap::defaults() + .chat + .voice_mute_hint(), phase: VoiceStripPhase::Active, microphone_live: true, microphone_muted: false, @@ -130,3 +133,34 @@ fn waveform_preserves_real_sample_order_width_and_channel_colors() { assert_eq!(buffer[(7, 1)].fg, Color::DarkGray); assert_eq!(buffer[(22, 1)].fg, Color::Magenta); } + +#[test] +fn voice_controls_follow_configured_mute_binding_and_unbinding() { + let mut strip = active_strip(); + let mut layouts = Vec::new(); + for (binding, expected) in [ + ("'f8'", "f8 mute"), + ("'ctrl-x m'", "ctrl+x m mute"), + ("[]", "/voice mute"), + ] { + let config = toml::from_str(&format!("[chat]\ntoggle_voice_mute = {binding}")).unwrap(); + strip.state.mute_hint = crate::keymap::RuntimeKeymap::from_config(&config) + .unwrap() + .chat + .voice_mute_hint(); + let (text, _) = rows(&strip, /*width*/ 80); + assert!(text.contains(expected)); + assert!(text.contains("/voice stop")); + for muted in [false, true] { + strip.state.microphone_muted = muted; + strip.state.activity = if muted { "muted" } else { "listening" }; + for width in [80, 22] { + let (text, _) = rows(&strip, width); + layouts.push(format!("{binding}, muted={muted}, width={width}:\n{text}")); + } + } + strip.state.microphone_muted = false; + strip.state.activity = "listening"; + } + insta::assert_snapshot!(layouts.join("\n\n")); +} diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 0d08afb225..6d482f2fd5 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -178,6 +178,7 @@ impl ChatWidget { self.bottom_pane .set_queued_message_edit_binding(self.queued_message_edit_hint_binding); self.bottom_pane.set_keymap_bindings(runtime_keymap); + self.update_realtime_footer(); self.request_redraw(); } } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 799f6d57b7..5345afec12 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -13,7 +13,7 @@ use crate::app_event::AppEvent; use crate::bottom_pane::VoiceStripPhase; use crate::bottom_pane::VoiceStripState; use crate::history_cell; -use crate::key_hint; +use crate::key_hint::KeyBindingListExt; use crate::motion::MotionMode; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::UserInput; @@ -23,7 +23,6 @@ use codex_protocol::models::MessagePhase; use codex_realtime_webrtc::RealtimeWebrtcSession; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; use codex_realtime_webrtc::StartedRealtimeWebrtcSession; -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use futures::future::AbortHandle; @@ -57,7 +56,6 @@ const MAX_REALTIME_AUDIO_METER_FRAMES: usize = 12; const MICROPHONE_METER_INTERVAL: Duration = Duration::from_millis(100); const SPEAKER_ACTIVITY_HOLD: Duration = Duration::from_millis(500); const INTERRUPTION_ACKNOWLEDGMENT: Duration = Duration::from_millis(400); -const REALTIME_MICROPHONE_SHORTCUT: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('x')); static NEXT_REALTIME_ATTEMPT_ID: AtomicU64 = AtomicU64::new(1); static NEXT_REALTIME_SPEECH_DELIVERY_ID: AtomicU64 = AtomicU64::new(1); diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs index cdb56ed1bc..e0a32111e2 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs @@ -30,22 +30,22 @@ impl ChatWidget { self.refresh_terminal_title(); } - pub(in crate::chatwidget) fn handle_realtime_microphone_shortcut( - &mut self, - key_event: KeyEvent, - ) -> bool { + pub(crate) fn realtime_microphone_shortcut_available(&self) -> bool { + (self.realtime_conversation.phase == RealtimeConversationPhase::Active + || (self.realtime_conversation.phase == RealtimeConversationPhase::Starting + && self.realtime_conversation.handle.is_some())) + && self.realtime_conversation.thread_id.is_some() + && self.realtime_conversation.thread_id == self.thread_id() + && self.bottom_pane.no_modal_or_popup_active() + } + + pub(crate) fn handle_realtime_microphone_shortcut(&mut self, key_event: KeyEvent) -> bool { if key_event.kind != KeyEventKind::Press - || !REALTIME_MICROPHONE_SHORTCUT.is_press(key_event) - || !(self.realtime_conversation.phase == RealtimeConversationPhase::Active - || (self.realtime_conversation.phase == RealtimeConversationPhase::Starting - && self.realtime_conversation.handle.is_some())) - || self.realtime_conversation.thread_id.is_none() - || self.realtime_conversation.thread_id != self.thread_id() - || !self.bottom_pane.no_modal_or_popup_active() + || !self.chat_keymap.toggle_voice_mute.is_pressed(key_event) + || !self.realtime_microphone_shortcut_available() { return false; } - self.toggle_realtime_microphone(); true } @@ -185,7 +185,7 @@ impl ChatWidget { .schedule_frame_in(MICROPHONE_METER_INTERVAL); } - pub(super) fn update_realtime_footer(&mut self) { + pub(in crate::chatwidget) fn update_realtime_footer(&mut self) { let microphone_live = self.realtime_microphone_is_listening(); let active = self.realtime_conversation.phase == RealtimeConversationPhase::Active; if !matches!( @@ -218,6 +218,7 @@ impl ChatWidget { "listening" }; self.bottom_pane.set_voice_strip(Some(VoiceStripState { + mute_hint: self.chat_keymap.voice_mute_hint(), phase: if active { VoiceStripPhase::Active } else { 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 e801ac4577..731d121a4d 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -62,6 +62,32 @@ async fn voice_mute_shortcut_accepts_raw_terminal_control_bytes() { assert!(ops.try_recv().is_err()); } +#[tokio::test] +async fn voice_mute_keymap_updates_the_active_handler_and_composer_hint() { + let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; + activate_voice(&mut chat); + let custom = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE); + for (binding, hint, handles_key) in [("'f8'", "f8 mute", true), ("[]", "/voice mute", false)] { + let config = toml::from_str::(&format!( + "[chat]\ntoggle_voice_mute = {binding}" + )) + .unwrap(); + let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + chat.apply_keymap_update(config, &runtime); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains(hint)); + assert!(!chat.handle_realtime_microphone_shortcut(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL + ))); + assert_eq!( + chat.handle_realtime_microphone_shortcut(custom), + handles_key + ); + assert_eq!(events.try_recv().is_ok(), handles_key); + assert!(!chat.realtime_conversation.microphone_muted); + } +} + #[tokio::test] async fn voice_composer_preserves_normal_colors_across_microphone_states() { use crate::render::renderable::Renderable; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 4591732c18..6f5fbdf8ed 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -41,6 +41,10 @@ pub(crate) use vim_search::VimSearchKeymap; #[path = "keymap/conflict_tests.rs"] mod conflict_tests; +#[cfg(test)] +#[path = "keymap/voice_tests.rs"] +mod voice_tests; + pub(crate) use bindings::KeymapContext; pub(crate) use bindings::bindings_for_action; pub(crate) use bindings::keymap_action_id; @@ -111,6 +115,9 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Toggle capture in the active voice session. + pub(crate) toggle_voice_mute: Vec, + chord_hints: Arc, /// Interrupt the active turn. pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. @@ -129,6 +136,14 @@ pub(crate) struct ChatKeymap { pub(crate) skip_question: Vec, } +impl ChatKeymap { + pub(crate) fn voice_mute_hint(&self) -> Option { + let action = keymap_action_id("chat", "toggle_voice_mute")?; + self.chord_hints + .primary_hint(action, &self.toggle_voice_mute) + } +} + /// Composer-level keybindings validated in the second app-scope conflict pass. /// /// App-level handlers execute before the composer receives input, so any key @@ -608,6 +623,15 @@ impl RuntimeKeymap { || configured_context_alias_is_used(&keymap.list, alias) || configured_context_alias_is_used(&keymap.approval, alias) }); + // Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default. + let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none() + && (configured_main_surface_alias_is_used(keymap, "ctrl-x") + || chords.bindings.iter().any(|binding| { + binding.action.context.overlaps(KeymapContext::Voice) + && binding.chord.prefix.parts() + == key_hint::ctrl(KeyCode::Char('x')).parts() + })); + let app = AppKeymap { open_agents: resolve_bindings( keymap.global.open_agents.as_ref(), @@ -661,6 +685,16 @@ impl RuntimeKeymap { }; let mut chat = ChatKeymap { + toggle_voice_mute: if voice_mute_default_is_shadowed { + Vec::new() + } else { + resolve_bindings( + keymap.chat.toggle_voice_mute.as_ref(), + &defaults.chat.toggle_voice_mute, + "tui.keymap.chat.toggle_voice_mute", + )? + }, + chord_hints: Arc::clone(&chords), interrupt_turn: resolve_bindings( keymap.chat.interrupt_turn.as_ref(), &defaults.chat.interrupt_turn, @@ -1499,6 +1533,8 @@ impl RuntimeKeymap { }, chords: Arc::default(), chat: ChatKeymap { + toggle_voice_mute: default_bindings![ctrl(KeyCode::Char('x'))], + chord_hints: Arc::default(), interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![ alt(KeyCode::Char(',')), @@ -1846,6 +1882,10 @@ impl RuntimeKeymap { ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("toggle_side_conversation", side_toggle_bindings.as_slice()), + ( + "chat.toggle_voice_mute", + self.chat.toggle_voice_mute.as_slice(), + ), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", @@ -1996,6 +2036,10 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ( + "chat.toggle_voice_mute", + self.chat.toggle_voice_mute.as_slice(), + ), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", @@ -2094,11 +2138,12 @@ impl RuntimeKeymap { KeymapContext::VimNormal, KeymapContext::VimOperator, KeymapContext::VimTextObject, - KeymapContext::Pager, ] { validate_unique(context.config_name(), context_bindings(context))?; } + validate_unique("pager", context_bindings(KeymapContext::Pager))?; + validate_no_reserved( "pager", context_bindings(KeymapContext::Pager), diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 4ba502305d..bfd05b9959 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -10,11 +10,13 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use std::sync::Arc; -/// Config context in which a keymap action is active. +/// Runtime context in which a keymap action is active. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) enum KeymapContext { Global, Chat, + /// Voice-only chat action, inactive in pagers and other overlays. + Voice, Composer, Editor, VimNormal, @@ -31,7 +33,7 @@ impl KeymapContext { pub(crate) const fn config_name(self) -> &'static str { match self { Self::Global => "global", - Self::Chat => "chat", + Self::Chat | Self::Voice => "chat", Self::Composer => "composer", Self::Editor => "editor", Self::VimNormal => "vim_normal", @@ -61,6 +63,8 @@ impl KeymapContext { (self, other), (Self::VimSearch, Self::VimNormal | Self::VimOperator) | (Self::VimNormal | Self::VimOperator, Self::VimSearch) + | (Self::Voice, Self::Pager) + | (Self::Pager, Self::Voice) | (Self::List, Self::Approval) | (Self::Approval, Self::List) | (Self::List, Self::Agents) @@ -73,7 +77,10 @@ impl KeymapContext { } const fn is_shared_main(self) -> bool { - matches!(self, Self::Global | Self::Chat | Self::Composer) + matches!( + self, + Self::Global | Self::Chat | Self::Voice | Self::Composer + ) } const fn is_main_editor(self) -> bool { @@ -262,6 +269,7 @@ define_runtime_action_bindings! { prompt_stack_back, skip_question, ], + "chat" => Voice, chat, chat [toggle_voice_mute], "composer" => Composer, composer, composer [ submit, queue, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index fbb350c3a0..5a2848cae5 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -69,6 +69,7 @@ const fn context_bit(context: KeymapContext) -> u16 { KeymapContext::Approval => 9, KeymapContext::Agents => 10, KeymapContext::VimSearch => 11, + KeymapContext::Voice => 12, } } @@ -513,6 +514,7 @@ Choose a different chord and retry.", KeymapContext::Pager => TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, KeymapContext::Global | KeymapContext::Chat + | KeymapContext::Voice | KeymapContext::Composer | KeymapContext::Editor | KeymapContext::VimNormal diff --git a/codex-rs/tui/src/keymap/voice_tests.rs b/codex-rs/tui/src/keymap/voice_tests.rs new file mode 100644 index 0000000000..7e66099edc --- /dev/null +++ b/codex-rs/tui/src/keymap/voice_tests.rs @@ -0,0 +1,85 @@ +//! Voice-keymap regressions for defaults, custom bindings, unbinding, and conflicts. +//! Voice shortcuts preserve ordinary chat bindings and unrelated chords. + +use super::*; +use crate::key_hint::KeyBindingListExt; +use pretty_assertions::assert_eq; + +#[test] +fn voice_mute_resolves_custom_bindings_unbinding_and_visible_hints() { + for (config, expected) in [ + ("", Some("ctrl + x")), + ("[chat]\ntoggle_voice_mute = 'f8'", Some("f8")), + ("[chat]\ntoggle_voice_mute = []", None), + ("[chat]\ntoggle_voice_mute = 'ctrl-x m'", Some("ctrl + x m")), + ] { + let config = toml::from_str::(config).expect("valid voice keymap config"); + let runtime = RuntimeKeymap::from_config(&config).expect("valid voice bindings"); + assert_eq!( + runtime + .primary_hint(KeymapContext::Chat, "toggle_voice_mute") + .map(ShortcutHint::display_label), + expected.map(str::to_string), + ); + } +} + +#[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'", + ] { + let config = toml::from_str::(config).unwrap(); + let runtime = RuntimeKeymap::from_config(&config).expect("existing binding stays valid"); + assert_eq!( + runtime.primary_hint(KeymapContext::Chat, "toggle_voice_mute"), + None + ); + } + for (binding, conflict) in [ + ("ctrl-k", "kill_line_end"), + ("ctrl-c", "fixed.interrupt_or_quit"), + ] { + let config = + toml::from_str::(&format!("[chat]\ntoggle_voice_mute = '{binding}'")) + .unwrap(); + assert!( + RuntimeKeymap::from_config(&config) + .unwrap_err() + .contains(conflict) + ); + } +} + +#[test] +fn voice_mute_chord_dispatches_only_in_its_active_context() { + let config = toml::from_str::("[chat]\ntoggle_voice_mute = 'ctrl-x m'").unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + let action = keymap_action_id("chat", "toggle_voice_mute").unwrap(); + let mut matcher = KeyChordMatcher::default(); + let now = tokio::time::Instant::now(); + let prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + let completion = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); + assert_eq!( + matcher.advance( + prefix, + &runtime.chords, + KeymapContextSet::new(KeymapContext::Pager), + now + ), + KeyChordMatch::PassThrough + ); + let contexts = KeymapContextSet::new(action.context); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, contexts, now), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(dispatch) = + matcher.advance(completion, &runtime.chords, contexts, now) + else { + panic!("mute chord completes") + }; + assert!(runtime.chat.toggle_voice_mute.is_pressed(dispatch)); +} diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 8fc1a41a36..32ce18e254 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -101,6 +101,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("chat", "Chat", "edit_queued_message", "Move up through questions, then edit the last queued message."), action("chat", "Chat", "prompt_stack_back", "Move back through questions toward the composer."), action("chat", "Chat", "skip_question", "Skip the focused question."), + action("chat", "Chat", "toggle_voice_mute", "Mute or unmute the voice microphone."), action("composer", "Composer", "submit", "Submit the current composer draft."), action("composer", "Composer", "queue", "Queue the draft while a task is running."), action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."), @@ -277,6 +278,7 @@ pub(super) fn binding_slot<'a>( ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), ("chat", "prompt_stack_back") => Some(&mut keymap.chat.prompt_stack_back), ("chat", "skip_question") => Some(&mut keymap.chat.skip_question), + ("chat", "toggle_voice_mute") => Some(&mut keymap.chat.toggle_voice_mute), ("composer", "submit") => Some(&mut keymap.composer.submit), ("composer", "queue") => Some(&mut keymap.composer.queue), ("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts), 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 7253b12166..0a93ffc014 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. - 141 actions, 1 customized, 5 unbound. + 142 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug 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 009afe80fe..3635d9fd3a 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. - 142 actions, 0 customized, 6 unbound. + 143 actions, 0 customized, 6 unbound. [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug 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 1c0a3e964b..d7e89b0e1e 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 (141 selectable) +tab: All (142 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) -tab: App (16 selectable) +tab: App (17 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) 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 0afe5b55f8..14056821ca 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,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 141 actions, 1 customized, 5 unbound. + 142 actions, 1 customized, 6 unbound. - [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug + [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug Type to search shortcuts › Global - Open Agents unbound 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 2871899292..67aca5fa7c 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. - 141 actions, 0 customized, 5 unbound. + 142 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug 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 08ffd0c929..1a36659e1c 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. - 141 actions, 0 customized, 5 unbound. + 142 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index 1f661cf25f..db06ed85fe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 141 actions, 0 customized, 5 unbound. + 142 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug