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 <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-03-17 14:11:16 -07:00
committed by GitHub
co-authored by Codex
parent 6d61e60a39
commit a49c15ba07
3 changed files with 113 additions and 12 deletions
+14 -2
View File
@@ -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<String> {
@@ -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();
@@ -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<String>,
warned_audio_only_submission: bool,
meter_placeholder_id: Option<String>,
pub(super) meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
capture_stop_flag: Option<Arc<AtomicBool>>,
#[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}"
));
}
}
}
@@ -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<Op>) {
}
}
fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
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<Op>) {
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::<Vec<_>>();
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;