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