Improve realtime footer status and response recovery

This commit is contained in:
Peter Bakkum
2026-03-11 07:17:02 -07:00
parent 302f5e648b
commit 6512f0fe92
9 changed files with 446 additions and 41 deletions
+108 -16
View File
@@ -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<String> = 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 {
@@ -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(()));
@@ -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<String>,
realtime_status_label: Option<String>,
}
#[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<String>,
) -> bool {
if self.realtime_status_label == realtime_status_label {
return false;
}
self.realtime_status_label = realtime_status_label;
true
}
}
#[cfg(not(target_os = "linux"))]
+45 -1
View File
@@ -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<String>,
/// 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<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -648,6 +653,19 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
}
}
if let Some(realtime_status_label) = props.realtime_status_label.as_ref() {
if let Some(existing) = line.as_mut() {
existing.spans.push(" · ".into());
existing
.spans
.push(Span::from(realtime_status_label.clone()).cyan());
} else {
line = Some(Line::from(vec![
Span::from(realtime_status_label.clone()).cyan(),
]));
}
}
line
}
@@ -655,10 +673,14 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
///
/// In practice this means the composer is idle, or it has a draft but is not currently running a
/// task, so the footer can spend the row on ambient context instead of "what to do next" text.
/// Realtime voice status is the exception: when it is active we keep the contextual row visible
/// even while work is running so the live meter can share space with the status line.
pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> 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 =
+9
View File
@@ -1135,6 +1135,15 @@ impl BottomPane {
self.request_redraw();
}
}
pub(crate) fn set_realtime_status_label(&mut self, realtime_status_label: Option<String>) {
if self
.composer
.set_realtime_status_label(realtime_status_label)
{
self.request_redraw();
}
}
}
#[cfg(not(target_os = "linux"))]
+5
View File
@@ -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<String>) {
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,
+21 -17
View File
@@ -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<String> {
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;
}
@@ -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 ⡿⣷⣄⠄
+10 -4
View File
@@ -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;