From a49c15ba07277bd6a4f9a93fdab1818b7035f692 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 14:11:16 -0700 Subject: [PATCH] Mirror realtime conversation end handling in app-server TUI (#14904) - mirror realtime close handling, ctrl-c behavior, and placeholder-triggered teardown in `tui_app_server` - keep the app-server TUI tests aligned with the direct TUI lifecycle Co-authored-by: Codex --- codex-rs/tui_app_server/src/chatwidget.rs | 16 +++- .../tui_app_server/src/chatwidget/realtime.rs | 35 ++++++--- .../tui_app_server/src/chatwidget/tests.rs | 74 +++++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 0b4fb7c184..06f2887621 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -7652,7 +7652,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 { @@ -7867,7 +7866,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 { @@ -8482,6 +8481,13 @@ impl ChatWidget { } return; } + 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; + } if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if self.is_cancellable_work_active() { @@ -9068,6 +9074,12 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { + 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 8a11cb4058..4ccf292401 100644 --- a/codex-rs/tui_app_server/src/chatwidget/realtime.rs +++ b/codex-rs/tui_app_server/src/chatwidget/realtime.rs @@ -21,11 +21,11 @@ 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, + pub(super) meter_placeholder_id: Option, #[cfg(not(target_os = "linux"))] capture_stop_flag: Option>, #[cfg(not(target_os = "linux"))] @@ -243,13 +243,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 +286,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 +295,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 +352,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; } }; @@ -400,7 +413,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}" + )); } } } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 07770182b2..9aac40cf7e 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -13,6 +13,7 @@ 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; @@ -1954,6 +1958,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!( @@ -4706,6 +4725,22 @@ 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(None).await; + chat.realtime_conversation.phase = RealtimeConversationPhase::Active; + + 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!(!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; @@ -4755,6 +4790,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(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(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;