Add a configurable F8 shortcut for voice conversations (#46071)

## What changed

- Bind `F8` to start or stop a voice conversation through the existing voice toggle handler, preserving its start guards and the composer draft.
- Expose `tui.keymap.chat.toggle_voice` in the configuration schema and keymap picker, with support for remapping, unbinding, and key chords.
- Let the default yield to existing `F8` bindings and overlapping chord prefixes, and validate explicit bindings against shortcut conflicts and reserved text input.
- Handle the shortcut only on key presses when no modal or popup is active.

## Testing

Add tests for default and custom bindings, conflicts, chord dispatch, live remapping and unbinding, draft preservation, start guards, and popup and key-event handling. Add a snapshot for the voice shortcut editor.

GitOrigin-RevId: e5e373e8eefa89bec4d5cbdca801616e312134b5
This commit is contained in:
Eric Traut
2026-09-17 01:31:14 +00:00
committed by copyberry
parent 36b84c81ec
commit ce03f22af6
18 changed files with 279 additions and 33 deletions
+2
View File
@@ -123,6 +123,8 @@ pub struct TuiGlobalKeymap {
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiChatKeymap {
/// Start or stop a voice conversation.
pub toggle_voice: Option<KeybindingsSpec>,
/// Toggle the microphone in an active voice conversation.
pub toggle_voice_mute: Option<KeybindingsSpec>,
/// Interrupt the active turn.
+10
View File
@@ -4139,6 +4139,7 @@
"previous_permission_mode": null,
"prompt_stack_back": null,
"skip_question": null,
"toggle_voice": null,
"toggle_voice_mute": null
},
"composer": {
@@ -4649,6 +4650,14 @@
],
"description": "Skip the focused question."
},
"toggle_voice": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Start or stop a voice conversation."
},
"toggle_voice_mute": {
"allOf": [
{
@@ -5008,6 +5017,7 @@
"previous_permission_mode": null,
"prompt_stack_back": null,
"skip_question": null,
"toggle_voice": null,
"toggle_voice_mute": null
}
},
@@ -2743,14 +2743,13 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> {
.await?;
app_server.remember_thread_history_mode(paginated_thread_id, ThreadHistoryMode::Legacy);
let resumed = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
paginated_thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
let resumed = Box::pin(app_server.resume_thread(
&app.local_settings,
app.config.clone(),
paginated_thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
))
.await?;
assert_eq!(resumed.session.thread_id, paginated_thread_id);
assert!(recorded_params(&requests, "thread/read").is_empty());
let resume_requests = recorded_params(&requests, "thread/resume");
@@ -2764,18 +2763,16 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> {
)
.await?;
assert!(!cells.is_empty());
app_server
.fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id)
Box::pin(app_server.fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id))
.await?;
let mut side_config = app.config.clone();
side_config.ephemeral = true;
app_server
.fork_side_thread(
&crate::local_settings::LocalSettings::from(&side_config),
side_config,
paginated_thread_id,
)
.await?;
Box::pin(app_server.fork_side_thread(
&crate::local_settings::LocalSettings::from(&side_config),
side_config,
paginated_thread_id,
))
.await?;
let paginated_reads = recorded_params(&requests, "thread/read");
assert!(!paginated_reads.is_empty());
@@ -78,6 +78,14 @@ impl ChatWidget {
return;
}
if key_event.kind == KeyEventKind::Press
&& self.chat_keymap.toggle_voice.is_pressed(key_event)
&& self.bottom_pane.no_modal_or_popup_active()
{
self.toggle_realtime_conversation();
return;
}
if self.handle_realtime_microphone_shortcut(key_event) {
return;
}
@@ -717,3 +717,69 @@ async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_m
chat.update_realtime_footer();
assert!(!render_bottom_popup(&chat, /*width*/ 45).contains("codex"));
}
#[tokio::test]
async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_draft() {
let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
chat.set_side_conversation_active(/*active*/ true);
chat.bottom_pane
.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
panic!("voice should report that side conversations are unsupported");
};
assert!(
cell.display_lines(/*width*/ 80)
.iter()
.any(|line| line.to_string().contains("side conversations"))
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("draft"));
assert!(!chat.realtime_conversation_is_running());
assert!(ops.try_recv().is_err());
}
#[tokio::test]
async fn voice_toggle_shortcut_stops_only_on_press_outside_popups() {
let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
let thread_id = activate_voice(&mut chat);
let shortcut = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE);
for kind in [KeyEventKind::Repeat, KeyEventKind::Release] {
chat.handle_key_event(KeyEvent { kind, ..shortcut });
assert!(chat.realtime_conversation_is_running());
}
chat.bottom_pane
.set_composer_text("/".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(shortcut);
assert!(chat.realtime_conversation_is_running());
assert!(ops.try_recv().is_err());
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
chat.handle_key_event(shortcut);
assert!(!chat.realtime_conversation_is_running());
assert!(
matches!(ops.try_recv(), Ok(AppCommand::RealtimeConversationStop { thread_id: stopped }) if stopped == thread_id)
);
}
#[tokio::test]
async fn voice_toggle_shortcut_respects_live_remapping_and_unbinding() {
let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
for (binding, stops) in [("'f9'", true), ("[]", false)] {
let config = toml::from_str::<codex_config::types::TuiKeymap>(&format!(
"[chat]\ntoggle_voice = {binding}"
))
.unwrap();
let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
chat.apply_keymap_update(config, &runtime);
activate_voice(&mut chat);
chat.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
assert!(chat.realtime_conversation_is_running());
chat.handle_key_event(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE));
assert_eq!(chat.realtime_conversation_is_running(), !stops);
assert_eq!(ops.try_recv().is_ok(), stops);
}
}
+31 -8
View File
@@ -115,6 +115,8 @@ pub(crate) struct AppKeymap {
/// handler code, not here.
#[derive(Clone, Debug)]
pub(crate) struct ChatKeymap {
/// Start or stop a voice conversation.
pub(crate) toggle_voice: Vec<KeyBinding>,
/// Toggle capture in the active voice session.
pub(crate) toggle_voice_mute: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
@@ -627,6 +629,13 @@ impl RuntimeKeymap {
|| configured_context_alias_is_used(&keymap.list, alias)
|| configured_context_alias_is_used(&keymap.approval, alias)
});
let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none()
&& (configured_main_surface_alias_is_used(keymap, "f8")
|| configured_context_alias_is_used(&keymap.vim_search, "f8")
|| chords.bindings.iter().any(|binding| {
binding.action.context.overlaps(KeymapContext::Chat)
&& binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts()
}));
// 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")
@@ -689,6 +698,15 @@ impl RuntimeKeymap {
};
let mut chat = ChatKeymap {
toggle_voice: if voice_toggle_default_is_shadowed {
Vec::new()
} else {
resolve_bindings(
keymap.chat.toggle_voice.as_ref(),
&defaults.chat.toggle_voice,
"tui.keymap.chat.toggle_voice",
)?
},
toggle_voice_mute: if voice_mute_default_is_shadowed {
Vec::new()
} else {
@@ -1570,6 +1588,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'))],
chord_hints: Arc::default(),
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
@@ -1864,6 +1883,7 @@ impl RuntimeKeymap {
/// backtracking, intentionally stay outside this configurable keymap.
fn validate_conflicts(&self) -> Result<(), String> {
for (action, bindings) in [
("toggle_voice", &self.chat.toggle_voice),
(
"previous_permission_mode",
&self.chat.previous_permission_mode,
@@ -1883,14 +1903,15 @@ impl RuntimeKeymap {
}
}
#[cfg(unix)]
if self
.app
.open_agents
.contains(&key_hint::ctrl(KeyCode::Char('z')))
{
return Err(
"tui.keymap.global.open_agents: ctrl-z is reserved for suspend".to_string(),
);
for (action, bindings) in [
("global.open_agents", &self.app.open_agents),
("chat.toggle_voice", &self.chat.toggle_voice),
] {
if bindings.contains(&key_hint::ctrl(KeyCode::Char('z'))) {
return Err(format!(
"tui.keymap.{action}: ctrl-z is reserved for suspend"
));
}
}
if self.app.open_agents.iter().any(|binding| {
matches!(binding.parts(), (KeyCode::Char(_), modifiers)
@@ -1923,6 +1944,7 @@ 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", self.chat.toggle_voice.as_slice()),
(
"chat.toggle_voice_mute",
self.chat.toggle_voice_mute.as_slice(),
@@ -2077,6 +2099,7 @@ impl RuntimeKeymap {
),
("copy", self.app.copy.as_slice()),
("clear_terminal", self.app.clear_terminal.as_slice()),
("chat.toggle_voice", self.chat.toggle_voice.as_slice()),
(
"chat.toggle_voice_mute",
self.chat.toggle_voice_mute.as_slice(),
+1
View File
@@ -260,6 +260,7 @@ define_runtime_action_bindings! {
toggle_side_conversation,
],
"chat" => Chat, chat, chat [
toggle_voice,
interrupt_turn,
decrease_reasoning_effort,
increase_reasoning_effort,
+99
View File
@@ -83,3 +83,102 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() {
};
assert!(runtime.chat.toggle_voice_mute.is_pressed(dispatch));
}
#[test]
fn voice_toggle_resolves_custom_bindings_unbinding_and_visible_hints() {
for (config, expected) in [
("", Some("f8")),
("[chat]\ntoggle_voice = 'f9'", Some("f9")),
("[chat]\ntoggle_voice = []", None),
("[chat]\ntoggle_voice = 'f8 v'", Some("f8 v")),
] {
let config = toml::from_str::<TuiKeymap>(config).unwrap();
let runtime = RuntimeKeymap::from_config(&config).unwrap();
assert_eq!(
runtime
.primary_hint(KeymapContext::Chat, "toggle_voice")
.map(ShortcutHint::display_label),
expected.map(str::to_string),
);
}
}
#[test]
fn voice_toggle_default_yields_to_existing_shortcuts_and_chord_prefixes() {
for config in [
"[editor]\nkill_line_end = 'f8'",
"[vim_search]\nnext = 'f8'",
"[chat]\ntoggle_voice_mute = 'f8'",
"[global]\nopen_transcript = 'f8 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"),
None
);
}
for (binding, conflict) in [
("ctrl-k", "kill_line_end"),
("ctrl-c", "fixed.interrupt_or_quit"),
("ctrl-t", "open_transcript"),
#[cfg(unix)]
("ctrl-z", "ctrl-z is reserved for suspend"),
] {
let config =
toml::from_str::<TuiKeymap>(&format!("[chat]\ntoggle_voice = '{binding}'")).unwrap();
assert!(
RuntimeKeymap::from_config(&config)
.unwrap_err()
.contains(conflict)
);
}
}
#[test]
fn voice_toggle_chord_dispatches_in_chat_without_an_active_voice_session() {
let config = toml::from_str::<TuiKeymap>("[chat]\ntoggle_voice = 'f8 v'").unwrap();
let runtime = RuntimeKeymap::from_config(&config).unwrap();
let mut matcher = KeyChordMatcher::default();
let now = tokio::time::Instant::now();
let prefix = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE);
assert_eq!(
matcher.advance(
prefix,
&runtime.chords,
KeymapContextSet::new(KeymapContext::Pager),
now
),
KeyChordMatch::PassThrough
);
let contexts = KeymapContextSet::new(KeymapContext::Chat);
assert!(matches!(
matcher.advance(prefix, &runtime.chords, contexts, now),
KeyChordMatch::Pending(_)
));
let KeyChordMatch::Completed(dispatch) = matcher.advance(
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
&runtime.chords,
contexts,
now,
) else {
panic!("voice toggle chord completes")
};
assert!(runtime.chat.toggle_voice.is_pressed(dispatch));
}
#[test]
fn voice_toggle_reserves_text_input() {
for binding in ["v", "shift-v", "2", "space"]
.into_iter()
.chain(cfg!(windows).then_some("ctrl-alt-q"))
{
let config =
toml::from_str::<TuiKeymap>(&format!("[chat]\ntoggle_voice = '{binding}'")).unwrap();
assert!(
RuntimeKeymap::from_config(&config)
.expect_err("voice shortcuts must not intercept typing")
.contains("printable keys are reserved for text input")
);
}
}
+16
View File
@@ -1172,6 +1172,22 @@ mod tests {
assert_snapshot!("keymap_picker_redo", redo_row);
}
#[test]
fn picker_voice_toggle_snapshot() {
assert_snapshot!(
"keymap_voice_toggle",
render_picker(
build_keymap_action_menu_params(
"chat".into(),
"toggle_voice".into(),
&RuntimeKeymap::defaults(),
&TuiKeymap::default(),
),
/*width*/ 80,
)
);
}
#[test]
fn picker_question_actions_snapshot() {
let runtime = RuntimeKeymap::defaults();
+2
View File
@@ -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", "Start or stop a voice conversation."),
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."),
@@ -282,6 +283,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") => Some(&mut keymap.chat.toggle_voice),
("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),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
146 actions, 1 customized, 5 unbound.
147 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
147 actions, 0 customized, 6 unbound.
148 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,11 +2,11 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (146 selectable)
tab: All (147 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable)
tab: App (17 selectable)
tab: App (18 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (69 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
146 actions, 1 customized, 6 unbound.
147 actions, 1 customized, 6 unbound.
[All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
146 actions, 0 customized, 5 unbound.
147 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.
146 actions, 0 customized, 5 unbound.
147 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.
146 actions, 0 customized, 5 unbound.
147 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -0,0 +1,22 @@
---
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
Config `tui.keymap.chat.toggle_voice`
Start or stop a voice conversation.
› 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.
– Remove custom binding (disabled) No custom root override to remove.
5. Back to shortcuts Return to the shortcut list.
Changes write the root `tui.keymap.*` override.
enter select · esc back