mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Improve realtime footer status and response recovery
This commit is contained in:
@@ -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"))]
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 ⡿⣷⣄⠄
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user