Add realtime audio device picker (#12850)

## Summary
- add a dedicated /audio picker for realtime microphone and speaker
selection
- persist realtime audio choices and prompt to restart only local audio
when voice is live
- add snapshot coverage for the new picker surfaces

## Validation
- cargo test -p codex-tui
- cargo insta accept
- just fix -p codex-tui
- just fmt
This commit is contained in:
Ahmed Ibrahim 2026-02-26 17:27:44 -08:00 committed by GitHub
parent 8715a6ef84
commit f90e97e414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 582 additions and 52 deletions

View file

@ -1,6 +1,7 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
@ -2020,6 +2021,9 @@ impl App {
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
@ -2445,6 +2449,56 @@ impl App {
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_speaker(name.as_deref())
}
};
match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());
if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.permissions.approval_policy.set(policy) {

View file

@ -29,6 +29,28 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeAudioDeviceKind {
Microphone,
Speaker,
}
impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
}
}
pub(crate) fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Speaker => "speaker",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@ -166,6 +188,26 @@ pub(crate) enum AppEvent {
personality: Personality,
},
/// Open the device picker for a realtime microphone or speaker.
OpenRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
},
/// Persist the selected realtime microphone or speaker to top-level config.
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
name: Option<String>,
},
/// Restart the selected realtime microphone or speaker locally.
RestartRealtimeAudioDevice {
kind: RealtimeAudioDeviceKind,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,

View file

@ -3,53 +3,47 @@ use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use tracing::warn;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioDeviceKind {
Input,
Output,
}
use crate::app_event::RealtimeAudioDeviceKind;
impl AudioDeviceKind {
fn noun(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
}
}
fn configured_name(self, config: &Config) -> Option<&str> {
match self {
Self::Input => config.realtime_audio.microphone.as_deref(),
Self::Output => config.realtime_audio.speaker.as_deref(),
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
let host = cpal::default_host();
let mut device_names = Vec::new();
for device in devices(&host, kind)? {
let Ok(name) = device.name() else {
continue;
};
if !device_names.contains(&name) {
device_names.push(name);
}
}
Ok(device_names)
}
pub(crate) fn select_configured_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Input, config)
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
}
pub(crate) fn select_configured_output_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Output, config)
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
}
fn select_device_and_config(
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let configured_name = kind.configured_name(config);
let configured_name = configured_name(kind, config);
let selected = configured_name
.and_then(|name| find_device_by_name(&host, kind, name))
.or_else(|| {
let default_device = default_device(&host, kind);
if let Some(name) = configured_name
&& default_device.is_some()
{
if let Some(name) = configured_name && default_device.is_some() {
warn!(
"configured {} audio device `{name}` was unavailable; falling back to system default",
kind.noun()
@ -63,9 +57,16 @@ fn select_device_and_config(
Ok((selected, stream_config))
}
fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
match kind {
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
}
}
fn find_device_by_name(
host: &cpal::Host,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
name: &str,
) -> Option<cpal::Device> {
let devices = devices(host, kind).ok()?;
@ -74,49 +75,55 @@ fn find_device_by_name(
.find(|device| device.name().ok().as_deref() == Some(name))
}
fn devices(host: &cpal::Host, kind: AudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
match kind {
AudioDeviceKind::Input => host
RealtimeAudioDeviceKind::Microphone => host
.input_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
AudioDeviceKind::Output => host
RealtimeAudioDeviceKind::Speaker => host
.output_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
}
}
fn default_device(host: &cpal::Host, kind: AudioDeviceKind) -> Option<cpal::Device> {
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
match kind {
AudioDeviceKind::Input => host.default_input_device(),
AudioDeviceKind::Output => host.default_output_device(),
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
}
}
fn default_config(
device: &cpal::Device,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
) -> Result<cpal::SupportedStreamConfig, String> {
match kind {
AudioDeviceKind::Input => device
RealtimeAudioDeviceKind::Microphone => device
.default_input_config()
.map_err(|err| format!("failed to get default input config: {err}")),
AudioDeviceKind::Output => device
RealtimeAudioDeviceKind::Speaker => device
.default_output_config()
.map_err(|err| format!("failed to get default output config: {err}")),
}
}
fn missing_device_error(kind: AudioDeviceKind, configured_name: Option<&str>) -> String {
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(AudioDeviceKind::Input, Some(name)) => format!(
"configured input audio device `{name}` was unavailable and no default input audio device was found"
),
(AudioDeviceKind::Output, Some(name)) => format!(
"configured output audio device `{name}` was unavailable and no default output audio device was found"
),
(AudioDeviceKind::Input, None) => "no input audio device available".to_string(),
(AudioDeviceKind::Output, None) => "no output audio device available".to_string(),
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
format!(
"configured microphone `{name}` was unavailable and no default input audio device was found"
)
}
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
format!(
"configured speaker `{name}` was unavailable and no default output audio device was found"
)
}
(RealtimeAudioDeviceKind::Microphone, None) => {
"no input audio device available".to_string()
}
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
}
}

View file

@ -395,6 +395,7 @@ pub(crate) struct ChatComposer {
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
@ -500,6 +501,7 @@ impl ChatComposer {
connectors_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
@ -577,10 +579,13 @@ impl ChatComposer {
self.realtime_conversation_enabled = enabled;
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.audio_device_selection_enabled = enabled;
}
/// Compatibility shim for tests that still toggle the removed steer mode flag.
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.voice_state.transcription_enabled = enabled;
if !enabled {
@ -2264,6 +2269,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@ -2480,6 +2486,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
{
@ -2515,6 +2522,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)?;
@ -3334,6 +3342,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@ -3396,6 +3405,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
) {
return true;
@ -3450,6 +3460,7 @@ impl ChatComposer {
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
@ -3457,6 +3468,7 @@ impl ChatComposer {
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
);

View file

@ -40,6 +40,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@ -51,6 +52,7 @@ impl CommandPopup {
flags.connectors_enabled,
flags.personality_command_enabled,
flags.realtime_conversation_enabled,
flags.audio_device_selection_enabled,
flags.windows_degraded_sandbox_active,
)
.into_iter()
@ -498,6 +500,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@ -518,6 +521,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@ -538,6 +542,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@ -566,6 +571,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@ -577,6 +583,36 @@ mod tests {
}
}
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"settings"),
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
);
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());

View file

@ -298,6 +298,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.composer.set_audio_device_selection_enabled(enabled);
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();

View file

@ -14,6 +14,7 @@ pub(crate) fn builtins_for_input(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
@ -26,6 +27,7 @@ pub(crate) fn builtins_for_input(
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.collect()
}
@ -36,6 +38,7 @@ pub(crate) fn find_builtin_command(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> Option<SlashCommand> {
builtins_for_input(
@ -43,6 +46,7 @@ pub(crate) fn find_builtin_command(
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
@ -57,6 +61,7 @@ pub(crate) fn has_builtin_prefix(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
@ -64,6 +69,7 @@ pub(crate) fn has_builtin_prefix(
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
@ -77,14 +83,14 @@ mod tests {
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", true, true, true, false, false);
let cmd = find_builtin_command("debug-config", true, true, true, false, false, false);
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}
#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", true, true, true, false, false),
find_builtin_command("clear", true, true, true, false, false, false),
Some(SlashCommand::Clear)
);
}
@ -92,7 +98,23 @@ mod tests {
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("realtime", true, true, true, false, false),
find_builtin_command("realtime", true, true, true, false, true, false),
None
);
}
#[test]
fn settings_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("settings", true, true, true, false, false, false),
None
);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
assert_eq!(
find_builtin_command("settings", true, true, true, true, false, false),
None
);
}

View file

@ -37,6 +37,9 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLineSetupView;
use crate::status::RateLimitWindowDisplay;
@ -855,6 +858,10 @@ impl ChatWidget {
&& cfg!(not(target_os = "linux"))
}
fn realtime_audio_device_selection_enabled(&self) -> bool {
self.realtime_conversation_enabled() && cfg!(feature = "voice-input")
}
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
///
/// The bottom pane only has one running flag, but this module treats it as a derived state of
@ -2882,6 +2889,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@ -3056,6 +3066,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@ -3219,6 +3232,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@ -3531,6 +3547,12 @@ impl ChatWidget {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
@ -5270,6 +5292,161 @@ impl ChatWidget {
});
}
pub(crate) fn open_realtime_audio_popup(&mut self) {
let items = [
RealtimeAudioDeviceKind::Microphone,
RealtimeAudioDeviceKind::Speaker,
]
.into_iter()
.map(|kind| {
let description = Some(format!(
"Current: {}",
self.current_realtime_audio_selection_label(kind)
));
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind });
})];
SelectionItem {
name: kind.title().to_string(),
description,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Settings".to_string()),
subtitle: Some("Configure settings for Codex.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
match list_realtime_audio_device_names(kind) {
Ok(device_names) => {
self.open_realtime_audio_device_selection_with_names(kind, device_names);
}
Err(err) => {
self.add_error_message(format!(
"Failed to load realtime {} devices: {err}",
kind.noun()
));
}
}
}
#[cfg(any(target_os = "linux", not(feature = "voice-input")))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
fn open_realtime_audio_device_selection_with_names(
&mut self,
kind: RealtimeAudioDeviceKind,
device_names: Vec<String>,
) {
let current_selection = self.current_realtime_audio_device_name(kind);
let current_available = current_selection
.as_deref()
.is_some_and(|name| device_names.iter().any(|device_name| device_name == name));
let mut items = vec![SelectionItem {
name: "System default".to_string(),
description: Some("Use your operating system default device.".to_string()),
is_current: current_selection.is_none(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
})],
dismiss_on_select: true,
..Default::default()
}];
if let Some(selection) = current_selection.as_deref()
&& !current_available
{
items.push(SelectionItem {
name: format!("Unavailable: {selection}"),
description: Some("Configured device is not currently available.".to_string()),
is_current: true,
is_disabled: true,
disabled_reason: Some("Reconnect the device or choose another one.".to_string()),
..Default::default()
});
}
items.extend(device_names.into_iter().map(|device_name| {
let persisted_name = device_name.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
kind,
name: Some(persisted_name.clone()),
});
})];
SelectionItem {
is_current: current_selection.as_deref() == Some(device_name.as_str()),
name: device_name,
actions,
dismiss_on_select: true,
..Default::default()
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Select {}", kind.title()).bold()));
header.push(Line::from(
"Saved devices apply to realtime voice only.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) {
let restart_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::RestartRealtimeAudioDevice { kind });
})];
let items = vec![
SelectionItem {
name: "Restart now".to_string(),
description: Some(format!("Restart local {} audio now.", kind.noun())),
actions: restart_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Apply later".to_string(),
description: Some(format!(
"Keep the current {} until local audio starts again.",
kind.noun()
)),
dismiss_on_select: true,
..Default::default()
},
];
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Restart {} now?", kind.title()).bold()));
header.push(Line::from(
"Configuration is saved. Restart local audio to use it immediately.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
let title = title.to_string();
let subtitle = subtitle.to_string();
@ -6523,6 +6700,8 @@ impl ChatWidget {
let realtime_conversation_enabled = self.realtime_conversation_enabled();
self.bottom_pane
.set_realtime_conversation_enabled(realtime_conversation_enabled);
self.bottom_pane
.set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled());
if !realtime_conversation_enabled && self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(Some(
"Realtime voice mode was closed because the feature was disabled.".to_string(),
@ -6612,6 +6791,17 @@ impl ChatWidget {
self.config.personality = Some(personality);
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
name: Option<String>,
) {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name,
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name,
}
}
/// Set the syntax theme override in the widget's config copy.
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
self.config.tui_theme = theme;
@ -6640,6 +6830,22 @@ impl ChatWidget {
.unwrap_or_else(|| self.current_collaboration_mode.model())
}
pub(crate) fn realtime_conversation_is_live(&self) -> bool {
self.realtime_conversation.is_active()
}
fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option<String> {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(),
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(),
}
}
fn current_realtime_audio_selection_label(&self, kind: RealtimeAudioDeviceKind) -> String {
self.current_realtime_audio_device_name(kind)
.unwrap_or_else(|| "System default".to_string())
}
fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));

View file

@ -41,6 +41,10 @@ impl RealtimeConversationUiState {
| RealtimeConversationPhase::Stopping
)
}
pub(super) fn is_active(&self) -> bool {
matches!(self.phase, RealtimeConversationPhase::Active)
}
}
#[derive(Clone, Debug, PartialEq)]
@ -278,8 +282,50 @@ impl ChatWidget {
#[cfg(target_os = "linux")]
fn start_realtime_local_audio(&mut self) {}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
if !self.realtime_conversation.is_active() {
return;
}
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.stop_realtime_microphone();
self.start_realtime_local_audio();
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
Err(err) => {
self.add_error_message(format!("Failed to start speaker output: {err}"));
}
}
}
}
self.request_redraw();
}
#[cfg(any(target_os = "linux", not(feature = "voice-input")))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_local_audio(&mut self) {
self.stop_realtime_microphone();
self.stop_realtime_speaker();
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {
self.realtime_conversation.meter_placeholder_id = None;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_microphone(&mut self) {
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
flag.store(true, Ordering::Relaxed);
}
@ -289,13 +335,12 @@ impl ChatWidget {
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_transcription_placeholder(&id);
}
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_speaker(&mut self) {
if let Some(player) = self.realtime_conversation.audio_player.take() {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {
self.realtime_conversation.meter_placeholder_id = None;
}
}

View file

@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back

View file

@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back

View file

@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Microphone
Saved devices apply to realtime voice only.
1. System default Use your operating system
default device.
2. Unavailable: Studio Mic (current) (disabled) Configured device is not
currently available.
(disabled: Reconnect the
device or choose another
one.)
3. Built-in Mic
4. USB Mic
Press enter to confirm or esc to go back

View file

@ -7,6 +7,8 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::LocalImageAttachment;
@ -6001,6 +6003,62 @@ async fn personality_selection_popup_snapshot() {
assert_snapshot!("personality_selection_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("realtime_audio_selection_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_selection_popup_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, 56);
assert_snapshot!("realtime_audio_selection_popup_narrow", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_microphone_picker_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.config.realtime_audio.microphone = Some("Studio Mic".to_string());
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Microphone,
vec!["Built-in Mic".to_string(), "USB Mic".to_string()],
);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("realtime_microphone_picker_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_picker_emits_persist_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Speaker,
vec!["Desk Speakers".to_string(), "Headphones".to_string()],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind::Speaker,
name: Some(name),
}) if name == "Headphones"
);
}
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;

View file

@ -51,6 +51,7 @@ pub enum SlashCommand {
Clear,
Personality,
Realtime,
Settings,
TestApproval,
// Debugging commands.
#[strum(serialize = "debug-m-drop")]
@ -89,6 +90,7 @@ impl SlashCommand {
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent => "switch the active agent thread",
@ -163,6 +165,7 @@ impl SlashCommand {
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent => true,
SlashCommand::Statusline => false,