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 <noreply@openai.com>
Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com>
This commit is contained in:
Ahmed Ibrahim
2026-03-17 21:04:58 -07:00
committed by GitHub
parent 770616414a
commit 3ce879c646
10 changed files with 326 additions and 67 deletions

View File

@@ -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<String> {
@@ -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();

View File

@@ -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<String>,
warned_audio_only_submission: bool,
meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
pub(super) meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
capture_stop_flag: Option<Arc<AtomicBool>>,
#[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) {

View File

@@ -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<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!(
@@ -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::<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(/*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;

View File

@@ -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<Vec<String>, String> {
Err(format!(
"Failed to load realtime {} devices: voice input is unavailable in this build",
kind.noun()
))
}
}
mod bottom_pane;
mod chatwidget;
mod cli;