diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 545edc90d2..72e33bdcb8 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -31,6 +31,7 @@ use codex_protocol::protocol::RealtimeHandoffRequested; use http::HeaderMap; use http::HeaderValue; use http::header::AUTHORIZATION; +use serde_json::Value; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -47,6 +48,8 @@ const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64; const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256; const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000; const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5"; +const ACTIVE_RESPONSE_CONFLICT_ERROR_PREFIX: &str = + "Conversation already has an active response in progress:"; const INTERRUPT_TOOL_OUTPUT_CANCELLED: &str = "Cancelled the current Codex operation."; const INTERRUPT_TOOL_OUTPUT_IDLE: &str = "No active Codex operation was running."; @@ -614,6 +617,9 @@ fn spawn_realtime_input_task( handoff_state: RealtimeHandoffState, ) -> JoinHandle<()> { tokio::spawn(async move { + let mut active_response_id: Option = None; + let mut pending_response_create = false; + let mut response_in_progress = false; loop { tokio::select! { text = user_text_rx.recv() => { @@ -624,10 +630,16 @@ fn spawn_realtime_input_task( warn!("failed to send input text: {mapped_error}"); break; } - if let Err(err) = writer.send_response_create().await { - let mapped_error = map_api_error(err); - warn!("failed to send text response.create: {mapped_error}"); - break; + if response_in_progress { + pending_response_create = true; + } else { + if let Err(err) = writer.send_response_create().await { + let mapped_error = map_api_error(err); + warn!("failed to send text response.create: {mapped_error}"); + break; + } + pending_response_create = false; + response_in_progress = true; } } Err(_) => break, @@ -662,12 +674,18 @@ fn spawn_realtime_input_task( warn!("failed to send handoff tool output: {mapped_error}"); break; } - if let Err(err) = writer.send_response_create().await { - let mapped_error = map_api_error(err); - warn!( - "failed to send handoff response.create: {mapped_error}" - ); - break; + if response_in_progress { + pending_response_create = true; + } else { + if let Err(err) = writer.send_response_create().await { + let mapped_error = map_api_error(err); + warn!( + "failed to send handoff response.create: {mapped_error}" + ); + break; + } + pending_response_create = false; + response_in_progress = true; } } } @@ -678,13 +696,87 @@ fn spawn_realtime_input_task( event = events.next_event() => { match event { Ok(Some(event)) => { - if let RealtimeEvent::HandoffRequested(handoff) = &event { - *handoff_state.active_handoff.lock().await = - Some(handoff.handoff_id.clone()); - *handoff_state.last_output_text.lock().await = None; + let mut forward_event = true; + let mut should_stop = false; + match &event { + RealtimeEvent::ConversationItemAdded(parsed) => { + match parsed.get("type").and_then(Value::as_str) { + Some("response.created") => { + active_response_id = parsed + .get("response") + .and_then(Value::as_object) + .and_then(|response| response.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + response_in_progress = true; + } + Some("response.done") => { + active_response_id = None; + response_in_progress = false; + if pending_response_create { + if let Err(err) = writer.send_response_create().await { + let mapped_error = map_api_error(err); + warn!( + "failed to send deferred response.create: {mapped_error}" + ); + break; + } + pending_response_create = false; + response_in_progress = true; + } + } + _ => {} + } + } + RealtimeEvent::ResponseCancelled(_) => { + active_response_id = None; + response_in_progress = false; + if pending_response_create { + if let Err(err) = writer.send_response_create().await { + let mapped_error = map_api_error(err); + warn!( + "failed to send deferred response.create after cancellation: {mapped_error}" + ); + break; + } + pending_response_create = false; + response_in_progress = true; + } + } + RealtimeEvent::HandoffRequested(handoff) => { + *handoff_state.active_handoff.lock().await = + Some(handoff.handoff_id.clone()); + *handoff_state.last_output_text.lock().await = None; + active_response_id = None; + response_in_progress = false; + } + RealtimeEvent::InterruptRequested(_) + | RealtimeEvent::ToolActionRequested(_) => { + active_response_id = None; + response_in_progress = false; + } + RealtimeEvent::CloseRequested(_) => { + active_response_id = None; + pending_response_create = false; + response_in_progress = false; + } + RealtimeEvent::Error(message) + if message.starts_with(ACTIVE_RESPONSE_CONFLICT_ERROR_PREFIX) => + { + warn!( + active_response_id, + "realtime rejected response.create because a response is already in progress; deferring follow-up response.create" + ); + pending_response_create = true; + response_in_progress = true; + forward_event = false; + } + RealtimeEvent::Error(_) => { + should_stop = true; + } + _ => {} } - let should_stop = matches!(&event, RealtimeEvent::Error(_)); - if events_tx.send(event).await.is_err() { + if forward_event && events_tx.send(event).await.is_err() { break; } if should_stop { diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 439695bcd8..da9c341bd2 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1032,6 +1032,237 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_text_waits_for_active_response_to_finish_before_creating_next_response() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_active_response", "instructions": "backend prompt" } + }), + json!({ + "type": "response.created", + "response": { "id": "resp_active" } + }), + ], + vec![], + vec![json!({ + "type": "response.done", + "response": { + "id": "resp_active", + "status": "completed", + "output": [] + } + })], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_active_response" => Some(()), + _ => None, + }) + .await; + + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + })) + .await?; + + let text_request = realtime_server.wait_for_request(0, 1).await; + assert_eq!( + text_request.body_json()["type"].as_str(), + Some("conversation.item.create") + ); + assert_eq!( + websocket_request_text(&text_request), + Some("hello".to_string()) + ); + + assert!( + tokio::time::timeout( + Duration::from_millis(200), + realtime_server.wait_for_request(0, 2) + ) + .await + .is_err() + ); + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + + let audio_request = realtime_server.wait_for_request(0, 2).await; + assert_eq!( + audio_request.body_json()["type"].as_str(), + Some("input_audio_buffer.append") + ); + + let response_create = realtime_server.wait_for_request(0, 3).await; + assert_eq!( + response_create.body_json()["type"].as_str(), + Some("response.create") + ); + + startup_server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_recovers_from_active_response_conflict_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_conflict", "instructions": "backend prompt" } + }), + json!({ + "type": "response.created", + "response": { "id": "resp_conflict" } + }), + ], + vec![json!({ + "type": "error", + "message": "Conversation already has an active response in progress: resp_conflict. Wait until the response is finished before creating a new one." + })], + vec![json!({ + "type": "response.done", + "response": { + "id": "resp_conflict", + "status": "completed", + "output": [] + } + })], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_conflict" => Some(()), + _ => None, + }) + .await; + + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + })) + .await?; + + let text_request = realtime_server.wait_for_request(0, 1).await; + assert_eq!( + text_request.body_json()["type"].as_str(), + Some("conversation.item.create") + ); + assert_eq!( + websocket_request_text(&text_request), + Some("hello".to_string()) + ); + + assert!( + tokio::time::timeout( + Duration::from_millis(200), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(message.clone()), + EventMsg::RealtimeConversationClosed(event) => { + Some(format!("closed:{:?}", event.reason)) + } + _ => None, + }), + ) + .await + .is_err() + ); + + assert!( + tokio::time::timeout( + Duration::from_millis(200), + realtime_server.wait_for_request(0, 2) + ) + .await + .is_err() + ); + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + + let audio_request = realtime_server.wait_for_request(0, 2).await; + assert_eq!( + audio_request.body_json()["type"].as_str(), + Some("input_audio_buffer.append") + ); + + let response_create = realtime_server.wait_for_request(0, 3).await; + assert_eq!( + response_create.body_json()["type"].as_str(), + Some("response.create") + ); + + startup_server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index dab8097ad4..4a572d3585 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -412,6 +412,7 @@ pub(crate) struct ChatComposer { status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, + realtime_status_label: Option, } #[derive(Clone, Debug)] @@ -533,6 +534,7 @@ impl ChatComposer { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -3195,6 +3197,7 @@ impl ChatComposer { status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), + realtime_status_label: self.realtime_status_label.clone(), } } @@ -3779,6 +3782,17 @@ impl ChatComposer { self.active_agent_label = active_agent_label; true } + + pub(crate) fn set_realtime_status_label( + &mut self, + realtime_status_label: Option, + ) -> bool { + if self.realtime_status_label == realtime_status_label { + return false; + } + self.realtime_status_label = realtime_status_label; + true + } } #[cfg(not(target_os = "linux"))] diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 1e4d5459cc..9eaeb94902 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -84,6 +84,11 @@ pub(crate) struct FooterProps { /// When both this label and the configured status line are available, they are rendered on the /// same row separated by ` · `. pub(crate) active_agent_label: Option, + /// Realtime voice status rendered as a compact contextual footer segment. + /// + /// When present, this shares the footer row with the configured status line and any active + /// agent label instead of replacing the whole footer with a dedicated override. + pub(crate) realtime_status_label: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -648,6 +653,19 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option Option bool { match props.mode { FooterMode::ComposerEmpty => true, - FooterMode::ComposerHasDraft => !props.is_task_running, + FooterMode::ComposerHasDraft => { + props.realtime_status_label.is_some() || !props.is_task_running + } FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => { false } @@ -1266,6 +1288,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1284,6 +1307,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1302,6 +1326,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1320,6 +1345,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1338,6 +1364,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1356,6 +1383,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1374,6 +1402,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1392,6 +1421,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1410,6 +1440,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1428,6 +1459,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }, ); @@ -1444,6 +1476,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer_with_mode_indicator( @@ -1473,6 +1506,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer_with_mode_indicator( @@ -1495,6 +1529,7 @@ mod tests { status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer("footer_status_line_overrides_shortcuts", props); @@ -1512,6 +1547,7 @@ mod tests { status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer("footer_status_line_yields_to_queue_hint", props); @@ -1529,6 +1565,7 @@ mod tests { status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer("footer_status_line_overrides_draft_idle", props); @@ -1546,6 +1583,7 @@ mod tests { status_line_value: None, // command timed out / empty status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer_with_mode_indicator( @@ -1568,6 +1606,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer_with_mode_indicator( @@ -1590,6 +1629,7 @@ mod tests { status_line_value: None, status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; // has status line and no collaboration mode @@ -1615,6 +1655,7 @@ mod tests { )), status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; snapshot_footer_with_mode_indicator( @@ -1637,6 +1678,7 @@ mod tests { status_line_value: None, status_line_enabled: false, active_agent_label: Some("Robie [explorer]".to_string()), + realtime_status_label: None, }; snapshot_footer("footer_active_agent_label", props); @@ -1654,6 +1696,7 @@ mod tests { status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: Some("Robie [explorer]".to_string()), + realtime_status_label: None, }; snapshot_footer("footer_status_line_with_active_agent_label", props); @@ -1677,6 +1720,7 @@ mod tests { )), status_line_enabled: true, active_agent_label: None, + realtime_status_label: None, }; let screen = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3ce48ccb69..a6360a2725 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1135,6 +1135,15 @@ impl BottomPane { self.request_redraw(); } } + + pub(crate) fn set_realtime_status_label(&mut self, realtime_status_label: Option) { + if self + .composer + .set_realtime_status_label(realtime_status_label) + { + self.request_redraw(); + } + } } #[cfg(not(target_os = "linux"))] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d81848e460..ca8f3bafe4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1092,6 +1092,11 @@ impl ChatWidget { self.bottom_pane.set_active_agent_label(active_agent_label); } + pub(crate) fn set_realtime_status_label(&mut self, realtime_status_label: Option) { + self.bottom_pane + .set_realtime_status_label(realtime_status_label); + } + /// Recomputes footer status-line content from config and current runtime state. /// /// This method is the status-line orchestrator: it parses configured item identifiers, diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 8116a6e082..265f5d5376 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -303,18 +303,23 @@ impl ChatWidget { None } - fn realtime_footer_hint_items(&self) -> Vec<(String, String)> { - let mut items = vec![("/realtime".to_string(), "stop live voice".to_string())]; - if let Some(meter_text) = self.realtime_conversation.meter_text.as_ref() { - items.push(("mic".to_string(), meter_text.clone())); + fn realtime_status_label(&self) -> Option { + match self.realtime_conversation.phase { + RealtimeConversationPhase::Inactive => None, + RealtimeConversationPhase::Starting => Some("voice starting".to_string()), + RealtimeConversationPhase::Active => Some( + self.realtime_conversation + .meter_text + .as_ref() + .map(|meter_text| format!("voice {meter_text}")) + .unwrap_or_else(|| "voice live".to_string()), + ), + RealtimeConversationPhase::Stopping => Some("voice stopping".to_string()), } - items } - fn refresh_realtime_footer_hint(&mut self) { - if self.realtime_conversation.is_live() && !self.realtime_conversation.requested_close { - self.set_footer_hint_override(Some(self.realtime_footer_hint_items())); - } + fn sync_realtime_status_label(&mut self) { + self.set_realtime_status_label(self.realtime_status_label()); } pub(crate) fn update_realtime_recording_meter( @@ -331,7 +336,7 @@ impl ChatWidget { } self.realtime_conversation.meter_text = Some(text); - self.refresh_realtime_footer_hint(); + self.sync_realtime_status_label(); true } @@ -343,7 +348,7 @@ impl ChatWidget { self.realtime_conversation.meter_text = None; self.realtime_conversation .warned_unsupported_composer_submission = false; - self.set_footer_hint_override(Some(self.realtime_footer_hint_items())); + self.sync_realtime_status_label(); self.submit_op(Op::RealtimeConversationStart(ConversationStartParams { prompt: REALTIME_CONVERSATION_PROMPT.to_string(), session_id: None, @@ -362,7 +367,7 @@ impl ChatWidget { self.realtime_conversation.requested_close = true; self.realtime_conversation.phase = RealtimeConversationPhase::Stopping; self.stop_realtime_local_audio(); - self.set_footer_hint_override(None); + self.sync_realtime_status_label(); if self.bottom_pane.is_task_running() { self.realtime_conversation.close_when_idle = true; @@ -393,7 +398,6 @@ impl ChatWidget { self.realtime_conversation.requested_close = true; self.realtime_conversation.close_when_idle = false; self.stop_realtime_local_audio(); - self.set_footer_hint_override(None); self.submit_op(Op::RealtimeConversationClose); self.reset_realtime_conversation_state(); @@ -415,7 +419,6 @@ impl ChatWidget { pub(super) fn reset_realtime_conversation_state(&mut self) { self.stop_realtime_local_audio(); - self.set_footer_hint_override(None); self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; self.realtime_conversation.requested_close = false; self.realtime_conversation.close_when_idle = false; @@ -423,6 +426,7 @@ impl ChatWidget { self.realtime_conversation.meter_text = None; self.realtime_conversation .warned_unsupported_composer_submission = false; + self.sync_realtime_status_label(); } pub(super) fn on_realtime_conversation_started( @@ -438,7 +442,7 @@ impl ChatWidget { self.realtime_conversation.session_id = ev.session_id; self.realtime_conversation .warned_unsupported_composer_submission = false; - self.set_footer_hint_override(Some(self.realtime_footer_hint_items())); + self.sync_realtime_status_label(); self.start_realtime_local_audio(); self.request_redraw(); } @@ -1393,7 +1397,7 @@ impl ChatWidget { self.realtime_conversation.meter_generation.wrapping_add(1); let meter_generation = self.realtime_conversation.meter_generation; self.realtime_conversation.meter_text = Some("⠤⠤⠤⠤".to_string()); - self.refresh_realtime_footer_hint(); + self.sync_realtime_status_label(); self.request_redraw(); let capture = match crate::voice::VoiceCapture::start_realtime( @@ -1403,7 +1407,7 @@ impl ChatWidget { Ok(capture) => capture, Err(err) => { self.realtime_conversation.meter_text = None; - self.refresh_realtime_footer_hint(); + self.sync_realtime_status_label(); self.add_error_message(format!("Failed to start microphone capture: {err}")); return; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap index ef1492e505..d4b74345ec 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap @@ -4,9 +4,9 @@ expression: term.backend().vt100().screen().contents() --- +• Working (0s • esc to interrupt) +› draft while realtime is live -› Ask Codex to do anything - - /realtime stop live voice mic ⡿⣷⣄⠄ 100% context left + gpt-5.4 · /Users/pbakkum/code/codex · voice ⡿⣷⣄⠄ diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b0c8ee9e2b..56fb6d4f7d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10477,10 +10477,16 @@ async fn realtime_recording_meter_renders_in_footer_snapshot() { chat.realtime_conversation .set_phase_for_test(super::realtime::RealtimeConversationPhase::Active); chat.realtime_conversation.set_meter_generation_for_test(7); - chat.set_footer_hint_override(Some(vec![( - "/realtime".to_string(), - "stop live voice".to_string(), - )])); + chat.bottom_pane.set_status_line_enabled(true); + chat.set_status_line(Some(ratatui::text::Line::from( + "gpt-5.4 · /Users/pbakkum/code/codex", + ))); + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "draft while realtime is live".to_string(), + Vec::new(), + Vec::new(), + ); assert!(chat.update_realtime_recording_meter(7, "⡿⣷⣄⠄".to_string())); let width: u16 = 80;