From 3ce879c64610cae8e460d3e8c126e57acbeb437d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 21:04:58 -0700 Subject: [PATCH] Handle realtime conversation end in the TUI (#14903) - close live realtime sessions on errors, ctrl-c, and active meter removal - centralize TUI realtime cleanup and avoid duplicate follow-up close info --------- Co-authored-by: Codex Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com> --- codex-rs/core/src/realtime_conversation.rs | 2 +- .../core/tests/suite/realtime_conversation.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 30 +++++-- codex-rs/tui/src/chatwidget/realtime.rs | 84 ++++++++++++------ codex-rs/tui/src/chatwidget/tests.rs | 87 +++++++++++++++++-- codex-rs/tui/src/lib.rs | 13 +++ codex-rs/tui_app_server/src/chatwidget.rs | 30 +++++-- .../tui_app_server/src/chatwidget/realtime.rs | 45 ++++++---- .../tui_app_server/src/chatwidget/tests.rs | 87 +++++++++++++++++-- codex-rs/tui_app_server/src/lib.rs | 13 +++ 10 files changed, 326 insertions(+), 67 deletions(-) diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index c1c117b2f..1ddd72d0f 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -56,7 +56,7 @@ const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000; const ACTIVE_RESPONSE_CONFLICT_ERROR_PREFIX: &str = "Conversation already has an active response in progress:"; -#[derive(Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RealtimeConversationEnd { Requested, TransportClosed, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index ad38c193b..87044eb86 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -440,7 +440,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res if std::env::var_os(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR).is_none() { return run_realtime_conversation_test_in_subprocess( "suite::realtime_conversation::conversation_start_preflight_failure_emits_realtime_error_only", - None, + /*openai_api_key*/ None, ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b32fbf8f1..c6fbdc424 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -39,7 +39,7 @@ use std::time::Instant; use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; @@ -1079,7 +1079,7 @@ impl ChatWidget { } fn realtime_audio_device_selection_enabled(&self) -> bool { - self.realtime_conversation_enabled() && cfg!(feature = "voice-input") + self.realtime_conversation_enabled() } /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -6370,7 +6370,7 @@ impl ChatWidget { }); } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { match list_realtime_audio_device_names(kind) { Ok(device_names) => { @@ -6385,12 +6385,12 @@ impl ChatWidget { } } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] fn open_realtime_audio_device_selection_with_names( &mut self, kind: RealtimeAudioDeviceKind, @@ -7865,7 +7865,6 @@ impl ChatWidget { self.request_realtime_conversation_close(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); - self.reset_realtime_conversation_state(); } } if feature == Feature::FastMode { @@ -8052,7 +8051,7 @@ impl ChatWidget { } pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_active() + self.realtime_conversation.is_live() } fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { @@ -8647,10 +8646,20 @@ impl ChatWidget { /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut /// is armed; this interrupts the turn but intentionally preserves background terminals. /// + /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the + /// first press always stops live voice, even when the composer contains the recording meter. + /// /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first /// quit. fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); + if self.realtime_conversation.is_live() { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.request_realtime_conversation_close(/*info_message*/ None); + return; + } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { @@ -9255,6 +9264,13 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { + #[cfg(not(target_os = "linux"))] + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.request_realtime_conversation_close(/*info_message*/ None); + } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 892e24183..2e4ab70e7 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -4,10 +4,13 @@ use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +#[cfg(not(target_os = "linux"))] use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; #[cfg(not(target_os = "linux"))] use std::sync::atomic::AtomicUsize; +#[cfg(not(target_os = "linux"))] +use std::time::Duration; const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely."; @@ -22,12 +25,14 @@ pub(super) enum RealtimeConversationPhase { #[derive(Default)] pub(super) struct RealtimeConversationUiState { - phase: RealtimeConversationPhase, + pub(super) phase: RealtimeConversationPhase, + #[cfg(not(target_os = "linux"))] audio_behavior: RealtimeAudioBehavior, requested_close: bool, session_id: Option, warned_audio_only_submission: bool, - meter_placeholder_id: Option, + #[cfg(not(target_os = "linux"))] + pub(super) meter_placeholder_id: Option, #[cfg(not(target_os = "linux"))] capture_stop_flag: Option>, #[cfg(not(target_os = "linux"))] @@ -40,6 +45,7 @@ pub(super) struct RealtimeConversationUiState { playback_queued_samples: Arc, } +#[cfg(not(target_os = "linux"))] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum RealtimeAudioBehavior { #[default] @@ -47,6 +53,7 @@ enum RealtimeAudioBehavior { PlaybackAware, } +#[cfg(not(target_os = "linux"))] impl RealtimeAudioBehavior { fn from_version(version: RealtimeConversationVersion) -> Self { match version { @@ -55,7 +62,6 @@ impl RealtimeAudioBehavior { } } - #[cfg(not(target_os = "linux"))] fn input_behavior( self, playback_queued_samples: Arc, @@ -79,6 +85,7 @@ impl RealtimeConversationUiState { ) } + #[cfg(not(target_os = "linux"))] pub(super) fn is_active(&self) -> bool { matches!(self.phase, RealtimeConversationPhase::Active) } @@ -233,7 +240,10 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; - self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; + #[cfg(not(target_os = "linux"))] + { + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; + } self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -273,22 +283,38 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; - self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; + #[cfg(not(target_os = "linux"))] + { + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; + } self.realtime_conversation.warned_audio_only_submission = false; } + fn fail_realtime_conversation(&mut self, message: String) { + self.add_error_message(message); + if self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(/*info_message*/ None); + } else { + self.reset_realtime_conversation_state(); + self.request_redraw(); + } + } + pub(super) fn on_realtime_conversation_started( &mut self, ev: RealtimeConversationStartedEvent, ) { if !self.realtime_conversation_enabled() { - self.submit_op(Op::RealtimeConversationClose); - self.reset_realtime_conversation_state(); + self.request_realtime_conversation_close(/*info_message*/ None); return; } self.realtime_conversation.phase = RealtimeConversationPhase::Active; self.realtime_conversation.session_id = ev.session_id; - self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::from_version(ev.version); + #[cfg(not(target_os = "linux"))] + { + self.realtime_conversation.audio_behavior = + RealtimeAudioBehavior::from_version(ev.version); + } self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -308,14 +334,16 @@ impl ChatWidget { } RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => { #[cfg(not(target_os = "linux"))] - if matches!( - self.realtime_conversation.audio_behavior, - RealtimeAudioBehavior::PlaybackAware - ) && let Some(player) = &self.realtime_conversation.audio_player { - // Once the server detects user speech or the current response is cancelled, - // any buffered assistant audio is stale and should stop gating mic input. - player.clear(); + if matches!( + self.realtime_conversation.audio_behavior, + RealtimeAudioBehavior::PlaybackAware + ) && let Some(player) = &self.realtime_conversation.audio_player + { + // Once the server detects user speech or the current response is cancelled, + // any buffered assistant audio is stale and should stop gating mic input. + player.clear(); + } } } RealtimeEvent::InputTranscriptDelta(_) => {} @@ -325,8 +353,7 @@ impl ChatWidget { RealtimeEvent::ConversationItemDone { .. } => {} RealtimeEvent::HandoffRequested(_) => {} RealtimeEvent::Error(message) => { - self.add_error_message(format!("Realtime voice error: {message}")); - self.reset_realtime_conversation_state(); + self.fail_realtime_conversation(format!("Realtime voice error: {message}")); } } } @@ -335,7 +362,10 @@ impl ChatWidget { let requested = self.realtime_conversation.requested_close; let reason = ev.reason; self.reset_realtime_conversation_state(); - if !requested && let Some(reason) = reason { + if !requested + && let Some(reason) = reason + && reason != "error" + { self.add_info_message( format!("Realtime voice mode closed: {reason}"), /*hint*/ None, @@ -387,9 +417,11 @@ impl ChatWidget { ) { Ok(capture) => capture, Err(err) => { - self.remove_transcription_placeholder(&placeholder_id); self.realtime_conversation.meter_placeholder_id = None; - self.add_error_message(format!("Failed to start microphone capture: {err}")); + self.remove_transcription_placeholder(&placeholder_id); + self.fail_realtime_conversation(format!( + "Failed to start microphone capture: {err}" + )); return; } }; @@ -431,7 +463,7 @@ impl ChatWidget { #[cfg(target_os = "linux")] fn start_realtime_local_audio(&mut self) {} - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) { if !self.realtime_conversation.is_active() { return; @@ -452,7 +484,9 @@ impl ChatWidget { self.realtime_conversation.audio_player = Some(player); } Err(err) => { - self.add_error_message(format!("Failed to start speaker output: {err}")); + self.fail_realtime_conversation(format!( + "Failed to start speaker output: {err}" + )); } } } @@ -460,7 +494,7 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } @@ -472,9 +506,7 @@ impl ChatWidget { } #[cfg(target_os = "linux")] - fn stop_realtime_local_audio(&mut self) { - self.realtime_conversation.meter_placeholder_id = None; - } + fn stop_realtime_local_audio(&mut self) {} #[cfg(not(target_os = "linux"))] fn stop_realtime_microphone(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ecebc4432..e234c5668 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7,12 +7,13 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] use crate::app_event::RealtimeAudioDeviceKind; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; +use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; @@ -95,6 +96,9 @@ use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::RealtimeConversationClosedEvent; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SessionConfiguredEvent; @@ -1957,6 +1961,21 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { } } +fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::RealtimeConversationClose) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected realtime close op but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected realtime close op but channel closed") + } + } + } +} + fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(op) = op_rx.try_recv() { assert!( @@ -4744,6 +4763,25 @@ async fn ctrl_c_shutdown_works_with_caps_lock() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + chat.bottom_pane + .set_composer_text("recording meter".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_realtime_close_op(&mut op_rx); + assert_eq!( + chat.realtime_conversation.phase, + RealtimeConversationPhase::Stopping + ); + assert_eq!(chat.bottom_pane.composer_text(), "recording meter"); + assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn ctrl_d_quits_without_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4793,6 +4831,45 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { assert_eq!(vec![PathBuf::from("/tmp/preview.png")], images); } +#[tokio::test] +async fn realtime_error_closes_without_followup_closed_info() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + + chat.on_realtime_conversation_realtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error("boom".to_string()), + }); + next_realtime_close_op(&mut op_rx); + + chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent { + reason: Some("error".to_string()), + }); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>(); + assert_snapshot!(rendered.join("\n\n"), @"■ Realtime voice error: boom"); +} + +#[cfg(not(target_os = "linux"))] +#[tokio::test] +async fn removing_active_realtime_placeholder_closes_realtime_conversation() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); + + chat.remove_transcription_placeholder(&placeholder_id); + + next_realtime_close_op(&mut op_rx); + assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); + assert_eq!( + chat.realtime_conversation.phase, + RealtimeConversationPhase::Stopping + ); +} + #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -7665,7 +7742,7 @@ async fn personality_selection_popup_snapshot() { assert_snapshot!("personality_selection_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_audio_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; @@ -7675,7 +7752,7 @@ async fn realtime_audio_selection_popup_snapshot() { assert_snapshot!("realtime_audio_selection_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[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; @@ -7685,7 +7762,7 @@ async fn realtime_audio_selection_popup_narrow_snapshot() { assert_snapshot!("realtime_audio_selection_popup_narrow", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_microphone_picker_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; @@ -7699,7 +7776,7 @@ async fn realtime_microphone_picker_popup_snapshot() { assert_snapshot!("realtime_microphone_picker_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[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; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6d52e020f..7fefaaccc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -67,6 +67,19 @@ mod app_server_tui_dispatch; mod ascii_animation; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] mod audio_device; +#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] +mod audio_device { + use crate::app_event::RealtimeAudioDeviceKind; + + pub(crate) fn list_realtime_audio_device_names( + kind: RealtimeAudioDeviceKind, + ) -> Result, String> { + Err(format!( + "Failed to load realtime {} devices: voice input is unavailable in this build", + kind.noun() + )) + } +} mod bottom_pane; mod chatwidget; mod cli; diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 922279657..ddb21f8f4 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -39,7 +39,7 @@ use std::time::Instant; use self::realtime::PendingSteerCompareKey; use crate::app_command::AppCommand; use crate::app_event::RealtimeAudioDeviceKind; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; @@ -1082,7 +1082,7 @@ impl ChatWidget { } fn realtime_audio_device_selection_enabled(&self) -> bool { - self.realtime_conversation_enabled() && cfg!(feature = "voice-input") + self.realtime_conversation_enabled() } /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -6177,7 +6177,7 @@ impl ChatWidget { }); } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { match list_realtime_audio_device_names(kind) { Ok(device_names) => { @@ -6192,12 +6192,12 @@ impl ChatWidget { } } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] fn open_realtime_audio_device_selection_with_names( &mut self, kind: RealtimeAudioDeviceKind, @@ -7675,7 +7675,6 @@ impl ChatWidget { self.request_realtime_conversation_close(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); - self.reset_realtime_conversation_state(); } } if feature == Feature::FastMode { @@ -7890,7 +7889,7 @@ impl ChatWidget { } pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_active() + self.realtime_conversation.is_live() } fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { @@ -8509,10 +8508,20 @@ impl ChatWidget { /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut /// is armed. /// + /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the + /// first press always stops live voice, even when the composer contains the recording meter. + /// /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first /// quit. fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); + if self.realtime_conversation.is_live() { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.request_realtime_conversation_close(/*info_message*/ None); + return; + } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { @@ -9112,6 +9121,13 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { + #[cfg(not(target_os = "linux"))] + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.request_realtime_conversation_close(/*info_message*/ None); + } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); diff --git a/codex-rs/tui_app_server/src/chatwidget/realtime.rs b/codex-rs/tui_app_server/src/chatwidget/realtime.rs index 8a11cb405..af7d849e2 100644 --- a/codex-rs/tui_app_server/src/chatwidget/realtime.rs +++ b/codex-rs/tui_app_server/src/chatwidget/realtime.rs @@ -21,11 +21,12 @@ pub(super) enum RealtimeConversationPhase { #[derive(Default)] pub(super) struct RealtimeConversationUiState { - phase: RealtimeConversationPhase, + pub(super) phase: RealtimeConversationPhase, requested_close: bool, session_id: Option, warned_audio_only_submission: bool, - meter_placeholder_id: Option, + #[cfg(not(target_os = "linux"))] + pub(super) meter_placeholder_id: Option, #[cfg(not(target_os = "linux"))] capture_stop_flag: Option>, #[cfg(not(target_os = "linux"))] @@ -44,6 +45,7 @@ impl RealtimeConversationUiState { ) } + #[cfg(not(target_os = "linux"))] pub(super) fn is_active(&self) -> bool { matches!(self.phase, RealtimeConversationPhase::Active) } @@ -243,13 +245,22 @@ impl ChatWidget { self.realtime_conversation.warned_audio_only_submission = false; } + fn fail_realtime_conversation(&mut self, message: String) { + self.add_error_message(message); + if self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(/*info_message*/ None); + } else { + self.reset_realtime_conversation_state(); + self.request_redraw(); + } + } + pub(super) fn on_realtime_conversation_started( &mut self, ev: RealtimeConversationStartedEvent, ) { if !self.realtime_conversation_enabled() { - self.submit_op(AppCommand::realtime_conversation_close()); - self.reset_realtime_conversation_state(); + self.request_realtime_conversation_close(/*info_message*/ None); return; } self.realtime_conversation.phase = RealtimeConversationPhase::Active; @@ -277,8 +288,7 @@ impl ChatWidget { RealtimeEvent::ConversationItemDone { .. } => {} RealtimeEvent::HandoffRequested(_) => {} RealtimeEvent::Error(message) => { - self.add_error_message(format!("Realtime voice error: {message}")); - self.reset_realtime_conversation_state(); + self.fail_realtime_conversation(format!("Realtime voice error: {message}")); } } } @@ -287,7 +297,10 @@ impl ChatWidget { let requested = self.realtime_conversation.requested_close; let reason = ev.reason; self.reset_realtime_conversation_state(); - if !requested && let Some(reason) = reason { + if !requested + && let Some(reason) = reason + && reason != "error" + { self.add_info_message( format!("Realtime voice mode closed: {reason}"), /*hint*/ None, @@ -341,9 +354,11 @@ impl ChatWidget { ) { Ok(capture) => capture, Err(err) => { - self.remove_transcription_placeholder(&placeholder_id); self.realtime_conversation.meter_placeholder_id = None; - self.add_error_message(format!("Failed to start microphone capture: {err}")); + self.remove_transcription_placeholder(&placeholder_id); + self.fail_realtime_conversation(format!( + "Failed to start microphone capture: {err}" + )); return; } }; @@ -382,7 +397,7 @@ impl ChatWidget { #[cfg(target_os = "linux")] fn start_realtime_local_audio(&mut self) {} - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) { if !self.realtime_conversation.is_active() { return; @@ -400,7 +415,9 @@ impl ChatWidget { self.realtime_conversation.audio_player = Some(player); } Err(err) => { - self.add_error_message(format!("Failed to start speaker output: {err}")); + self.fail_realtime_conversation(format!( + "Failed to start speaker output: {err}" + )); } } } @@ -408,7 +425,7 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } @@ -420,9 +437,7 @@ impl ChatWidget { } #[cfg(target_os = "linux")] - fn stop_realtime_local_audio(&mut self) { - self.realtime_conversation.meter_placeholder_id = None; - } + fn stop_realtime_local_audio(&mut self) {} #[cfg(not(target_os = "linux"))] fn stop_realtime_microphone(&mut self) { diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 29e56bb2b..d1271fa1a 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7,12 +7,13 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] use crate::app_event::RealtimeAudioDeviceKind; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; +use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; @@ -94,6 +95,9 @@ use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::RealtimeConversationClosedEvent; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SessionConfiguredEvent; @@ -1955,6 +1959,21 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { } } +fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::RealtimeConversationClose) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected realtime close op but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected realtime close op but channel closed") + } + } + } +} + fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(op) = op_rx.try_recv() { assert!( @@ -4707,6 +4726,25 @@ async fn ctrl_c_shutdown_works_with_caps_lock() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + chat.bottom_pane + .set_composer_text("recording meter".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_realtime_close_op(&mut op_rx); + assert_eq!( + chat.realtime_conversation.phase, + RealtimeConversationPhase::Stopping + ); + assert_eq!(chat.bottom_pane.composer_text(), "recording meter"); + assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn ctrl_d_quits_without_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4756,6 +4794,45 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { assert_eq!(vec![PathBuf::from("/tmp/preview.png")], images); } +#[tokio::test] +async fn realtime_error_closes_without_followup_closed_info() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + + chat.on_realtime_conversation_realtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error("boom".to_string()), + }); + next_realtime_close_op(&mut op_rx); + + chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent { + reason: Some("error".to_string()), + }); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>(); + assert_snapshot!(rendered.join("\n\n"), @"■ Realtime voice error: boom"); +} + +#[cfg(not(target_os = "linux"))] +#[tokio::test] +async fn removing_active_realtime_placeholder_closes_realtime_conversation() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); + + chat.remove_transcription_placeholder(&placeholder_id); + + next_realtime_close_op(&mut op_rx); + assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); + assert_eq!( + chat.realtime_conversation.phase, + RealtimeConversationPhase::Stopping + ); +} + #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -7602,7 +7679,7 @@ async fn personality_selection_popup_snapshot() { assert_snapshot!("personality_selection_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_audio_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; @@ -7612,7 +7689,7 @@ async fn realtime_audio_selection_popup_snapshot() { assert_snapshot!("realtime_audio_selection_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[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; @@ -7622,7 +7699,7 @@ async fn realtime_audio_selection_popup_narrow_snapshot() { assert_snapshot!("realtime_audio_selection_popup_narrow", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_microphone_picker_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; @@ -7636,7 +7713,7 @@ async fn realtime_microphone_picker_popup_snapshot() { assert_snapshot!("realtime_microphone_picker_popup", popup); } -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] #[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; diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 62a428918..a66792348 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -78,6 +78,19 @@ mod app_server_session; mod ascii_animation; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] mod audio_device; +#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] +mod audio_device { + use crate::app_event::RealtimeAudioDeviceKind; + + pub(crate) fn list_realtime_audio_device_names( + kind: RealtimeAudioDeviceKind, + ) -> Result, String> { + Err(format!( + "Failed to load realtime {} devices: voice input is unavailable in this build", + kind.noun() + )) + } +} mod bottom_pane; mod chatwidget; mod cli;