mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user