mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
committed by
GitHub
Unverified
parent
770616414a
commit
3ce879c646
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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"))]
|
||||
@@ -40,6 +45,7 @@ pub(super) struct RealtimeConversationUiState {
|
||||
playback_queued_samples: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[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<AtomicUsize>,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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!(
|
||||
@@ -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::<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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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