mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
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
This commit is contained in:
committed by
copyberry
parent
98c7c0415b
commit
45305dd229
@@ -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
|
||||
}
|
||||
|
||||
+52
@@ -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 ▅▆█
|
||||
@@ -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<ShortcutHint>,
|
||||
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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<codex_config::types::TuiKeymap>(&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;
|
||||
|
||||
@@ -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<KeyBinding>,
|
||||
chord_hints: Arc<RuntimeChordKeymap>,
|
||||
/// Interrupt the active turn.
|
||||
pub(crate) interrupt_turn: Vec<KeyBinding>,
|
||||
/// Decrease the active reasoning effort.
|
||||
@@ -129,6 +136,14 @@ pub(crate) struct ChatKeymap {
|
||||
pub(crate) skip_question: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
impl ChatKeymap {
|
||||
pub(crate) fn voice_mute_hint(&self) -> Option<ShortcutHint> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<TuiKeymap>(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::<TuiKeymap>(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::<TuiKeymap>(&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::<TuiKeymap>("[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));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user