TUI support for buffer experience (#29919)

This commit is contained in:
Eric Traut authored and GitHub committed 2026-06-24 19:50:50 -07:00
1 parent 3e51b46eba
commit 6801941cfe
18 files changed
+620 -3

No files matched your search

@@ -154,6 +154,7 @@ impl ChatWidget {
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep),
safety_buffering: SafetyBufferingState::default(),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
+3 -1
View File
@@ -147,6 +147,9 @@ impl ChatWidget {
ServerNotification::ModelVerification(notification) => {
self.on_app_server_model_verification(&notification.verifications)
}
ServerNotification::ModelSafetyBufferingUpdated(notification) => {
self.on_model_safety_buffering_updated(notification, replay_kind)
}
ServerNotification::Warning(notification) => self.on_warning(notification.message),
ServerNotification::GuardianWarning(notification) => {
self.on_warning(notification.message)
@@ -208,7 +211,6 @@ impl ChatWidget {
| ServerNotification::ExternalAgentConfigImportProgress(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
| ServerNotification::ModelSafetyBufferingUpdated(_)
| ServerNotification::TurnModerationMetadata(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
+1
View File
@@ -24,6 +24,7 @@ impl ChatWidget {
duration_ms,
} = turn;
if matches!(status, TurnStatus::InProgress) {
self.turn_lifecycle.last_turn_id = Some(turn_id.clone());
self.last_non_retry_error = None;
self.on_task_started();
}
@@ -0,0 +1,196 @@
//! Safety-buffering status and retry UI for active turns.
use super::*;
use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
const SAFETY_BUFFERING_PROMPT_VIEW_ID: &str = "safety-buffering-prompt";
const SAFETY_BUFFERING_MESSAGE_WITH_RETRY: &str = "This request requires additional safety checks, which can take extra time. You can retry with a faster model for a quicker response, though it may be less capable of handling complex requests.";
const SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY: &str =
"This request requires additional safety checks, which can take extra time.";
#[derive(Debug)]
struct ActiveSafetyBuffering {
turn_id: String,
retry_prompt_shown: bool,
agent_message_started: bool,
}
#[derive(Debug, Default)]
pub(super) struct SafetyBufferingState {
submitted_turn: Option<(String, AppCommand)>,
active: Option<ActiveSafetyBuffering>,
}
impl ChatWidget {
pub(crate) fn record_safety_buffering_turn(&mut self, turn_id: String, turn: &AppCommand) {
self.safety_buffering.submitted_turn = Some((turn_id, turn.clone()));
}
pub(super) fn reset_safety_buffering_for_turn_start(&mut self) {
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.safety_buffering.active = None;
}
pub(crate) fn clear_safety_buffering(&mut self) {
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.safety_buffering = SafetyBufferingState::default();
}
pub(super) fn mark_safety_buffering_agent_message_started(&mut self) {
if let Some(active) = self.safety_buffering.active.as_mut() {
active.agent_message_started = true;
}
}
pub(super) fn safety_buffering_is_waiting(&self) -> bool {
self.safety_buffering
.active
.as_ref()
.is_some_and(|active| !active.agent_message_started)
}
pub(crate) fn can_retry_safety_buffered_turn(&self, turn_id: &str) -> bool {
self.turn_lifecycle.agent_turn_running
&& self
.safety_buffering
.active
.as_ref()
.is_some_and(|active| active.turn_id == turn_id && !active.agent_message_started)
}
pub(crate) fn prepare_safety_buffering_retry(&mut self) {
let cancel_edit = std::mem::take(&mut self.cancel_edit);
self.last_rendered_user_message_display = None;
self.finalize_turn();
self.cancel_edit = cancel_edit;
self.input_queue.user_turn_pending_start = true;
}
pub(crate) fn fail_safety_buffering_retry(&mut self) {
self.input_queue.user_turn_pending_start = false;
self.clear_safety_buffering();
let prompt = self.cancel_edit.prompt.take();
self.clear_cancel_edit();
if let Some(prompt) = prompt {
self.restore_user_message_to_composer(prompt);
}
}
pub(super) fn on_model_safety_buffering_updated(
&mut self,
notification: ModelSafetyBufferingUpdatedNotification,
replay_kind: Option<ReplayKind>,
) {
let ModelSafetyBufferingUpdatedNotification {
turn_id,
show_buffering_ui,
faster_model,
..
} = notification;
if matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages))
|| !self.turn_lifecycle.agent_turn_running
|| self.turn_lifecycle.last_turn_id.as_deref() != Some(turn_id.as_str())
{
return;
}
if !show_buffering_ui {
if self
.safety_buffering
.active
.as_ref()
.is_some_and(|active| active.turn_id == turn_id)
{
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.safety_buffering.active = None;
self.restore_reasoning_status_header();
}
return;
}
let retry_turn = self
.safety_buffering
.submitted_turn
.as_ref()
.filter(|(submitted_turn_id, _)| replay_kind.is_none() && submitted_turn_id == &turn_id)
.map(|(_, turn)| turn.clone());
let thread_id = self.thread_id;
let can_offer_retry = faster_model.is_some() && retry_turn.is_some() && thread_id.is_some();
if !can_offer_retry {
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
}
let previous_active = self
.safety_buffering
.active
.as_ref()
.filter(|active| active.turn_id == turn_id);
let retry_prompt_shown = previous_active.is_some_and(|active| active.retry_prompt_shown);
let should_show_retry_prompt = can_offer_retry && !retry_prompt_shown;
let agent_message_started =
previous_active.is_some_and(|active| active.agent_message_started);
self.safety_buffering.active = Some(ActiveSafetyBuffering {
turn_id: turn_id.clone(),
retry_prompt_shown: retry_prompt_shown || should_show_retry_prompt,
agent_message_started,
});
let message = if can_offer_retry {
SAFETY_BUFFERING_MESSAGE_WITH_RETRY
} else {
SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY
};
self.bottom_pane.ensure_status_indicator();
self.set_status(
"Working".to_string(),
Some(message.to_string()),
StatusDetailsCapitalization::Preserve,
/*details_max_lines*/ 6,
);
let (Some(faster_model), Some(turn), Some(thread_id)) =
(faster_model, retry_turn, thread_id)
else {
return;
};
if !should_show_retry_prompt {
return;
}
let header = ColumnRenderable::with(vec![
Box::new(Line::from("Additional safety checks").bold()) as Box<dyn Renderable>,
Box::new(
Paragraph::new(Line::from(SAFETY_BUFFERING_MESSAGE_WITH_RETRY).dim())
.wrap(Wrap { trim: false }),
),
]);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
header: Box::new(header),
items: vec![
SelectionItem {
name: "Retry with a faster model".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id: turn_id.clone(),
model: faster_model.clone(),
turn: turn.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep waiting".to_string(),
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
});
}
}
@@ -27,6 +27,7 @@ impl ChatWidget {
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
self.clear_safety_buffering();
self.thread_name = session.thread_name.clone();
self.current_goal_status_indicator = None;
self.current_goal_status = None;
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: popup
---
Additional safety checks
This request requires additional safety checks, which can take extra time.
You can retry with a faster model for a quicker response, though it may be
less capable of handling complex requests.
› 1. Retry with a faster model
2. Keep waiting
Press enter to confirm or esc to go back
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: "render_bottom_popup(&chat, 80)"
---
• Working (0s • esc to interrupt)
└ This request requires additional safety checks, which can take extra time.
› Ask Codex to do anything
gpt-5.5 default · /tmp/project
+6
View File
@@ -203,6 +203,11 @@ impl ChatWidget {
// (between **/**) as the chunk header. Show this header as status.
self.reasoning_buffer.push_str(&delta);
if self.safety_buffering_is_waiting() {
self.request_redraw();
return;
}
if self.unified_exec_wait_streak.is_some() {
// Unified exec waiting should take precedence over reasoning-derived status headers.
self.request_redraw();
@@ -383,6 +388,7 @@ impl ChatWidget {
pub(super) fn handle_streaming_delta(&mut self, delta: String) {
if !delta.is_empty() {
self.record_visible_turn_activity();
self.mark_safety_buffering_agent_message_started();
}
if self.stream_controller.is_none() {
// Before starting an agent stream, flush any active exec cell group.
+1
View File
@@ -77,6 +77,7 @@ pub(super) use codex_app_server_protocol::MarketplaceUpgradeResponse;
pub(super) use codex_app_server_protocol::McpServerStartupState;
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
pub(super) use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
pub(super) use codex_app_server_protocol::NonSteerableTurnKind;
@@ -61,6 +61,194 @@ fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::Threa
}
}
fn start_safety_buffering_test_turn(
chat: &mut ChatWidget,
op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,
) -> (ThreadId, &'static str, Op) {
let thread_id = ThreadId::new();
let turn_id = "turn-safety-buffering";
chat.thread_id = Some(thread_id);
chat.submit_user_message(UserMessage::from("Explain the request"));
let turn = next_submit_op(op_rx);
assert_matches!(&turn, Op::UserTurn { .. });
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: AppServerTurn {
id: turn_id.to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
started_at: Some(0),
completed_at: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
(thread_id, turn_id, turn)
}
fn safety_buffering_notification(
thread_id: ThreadId,
turn_id: &str,
faster_model: Option<&str>,
) -> ModelSafetyBufferingUpdatedNotification {
ModelSafetyBufferingUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
model: "current-model".to_string(),
use_cases: Vec::new(),
reasons: Vec::new(),
show_buffering_ui: true,
faster_model: faster_model.map(str::to_string),
}
}
#[tokio::test]
async fn safety_buffering_offers_one_retry_with_app_wording() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(notification),
/*replay_kind*/ None,
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("safety_buffering_retry_prompt", popup);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (event_thread_id, event_turn_id, model, turn) = loop {
match rx.try_recv() {
Ok(AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id,
model,
turn,
}) => break (thread_id, turn_id, model, turn),
Ok(_) => continue,
Err(err) => panic!("expected safety-buffering retry event: {err}"),
}
};
assert_eq!(event_thread_id, thread_id);
assert_eq!(event_turn_id, turn_id);
assert_eq!(model, "faster-model");
assert_matches!(turn, Op::UserTurn { .. });
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
}
#[tokio::test]
async fn safety_buffering_stops_retrying_after_agent_message_starts() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
thread_id,
turn_id,
Some("faster-model"),
)),
/*replay_kind*/ None,
);
assert!(chat.can_retry_safety_buffered_turn(turn_id));
chat.on_agent_message_delta("Visible response".to_string());
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
}
#[tokio::test]
async fn safety_buffering_without_retry_shows_short_app_message() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, turn) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
thread_id, turn_id, /*faster_model*/ None,
)),
/*replay_kind*/ None,
);
let render_popup = |chat: &ChatWidget| {
normalize_snapshot_paths(render_bottom_popup(chat, /*width*/ 80))
};
let popup = render_popup(&chat);
assert_chatwidget_snapshot!("safety_buffering_status_without_retry", popup,);
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
chat.record_safety_buffering_turn("other-turn".to_string(), &turn);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
/*replay_kind*/ None,
);
assert_eq!(render_popup(&chat), popup);
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(notification),
Some(ReplayKind::ThreadSnapshot),
);
assert_eq!(render_popup(&chat), popup);
}
#[tokio::test]
async fn safety_buffering_ignores_hidden_stale_and_historical_updates() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
hidden.show_buffering_ui = false;
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(hidden),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
thread_id,
"stale-turn",
Some("faster-model"),
)),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
thread_id,
turn_id,
Some("faster-model"),
)),
Some(ReplayKind::ResumeInitialMessages),
);
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(hidden.clone()),
/*replay_kind*/ None,
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
hidden.show_buffering_ui = false;
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(hidden),
/*replay_kind*/ None,
);
assert_eq!(
chat.bottom_pane
.status_widget()
.expect("status indicator should be visible")
.details(),
None
);
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
}
#[tokio::test]
async fn invalid_url_elicitation_is_declined() {
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
@@ -969,6 +1157,24 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn app_server_safety_access_errors_render_dedicated_notice() {
let message = "Invalid prompt: we've limited access to this content for safety reasons.";
for message in [
message.to_string(),
json!({ "error": { "message": message } }).to_string(),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_non_retry_error(message, /*codex_error_info*/ None);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This content can't be shown"));
assert!(!rendered.contains("Invalid prompt:"));
}
}
#[tokio::test]
async fn live_app_server_model_verification_renders_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -5,6 +5,9 @@
use super::*;
const SAFETY_ACCESS_BLOCK_PREFIX: &str =
"Invalid prompt: we've limited access to this content for safety reasons.";
impl ChatWidget {
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
///
@@ -48,6 +51,7 @@ impl ChatWidget {
pub(super) fn on_task_started(&mut self) {
self.input_queue.user_turn_pending_start = false;
self.reset_safety_buffering_for_turn_start();
self.turn_lifecycle.start(Instant::now());
self.transcript.reset_turn_flags();
self.adaptive_chunking.reset();
@@ -297,6 +301,7 @@ impl ChatWidget {
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
/// and should continue to drive the bottom-pane running indicator while it is in progress.
pub(super) fn finalize_turn(&mut self) {
self.clear_safety_buffering();
// Drop preview-only stream tail content on any termination path before
// failed-cell finalization, so transient tail cells are never persisted.
self.clear_active_stream_tail();
@@ -424,6 +429,18 @@ impl ChatWidget {
.is_some_and(is_app_server_cyber_policy_error)
{
self.on_cyber_policy_error();
} else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)
|| serde_json::from_str::<serde_json::Value>(&message).is_ok_and(|response| {
response["error"]["message"]
.as_str()
.is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX))
})
{
self.input_queue.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_safety_access_block_event());
self.request_redraw();
self.maybe_send_next_queued_input();
} else if let Some(info) = codex_error_info
.as_ref()
.and_then(app_server_rate_limit_error_kind)