Report terminal completion states and blocking reasons (#54114)

## Why

Terminal status previously collapsed completed and failed turns into `idle` and reported blocking prompts without a reason. Blocking detection also depended on terminal title settings.

## What changed

- Emit `done` and `error` through OSC 7501 for live turn results, retaining them until deliberate keyboard, paste, or mouse input acknowledges them. Mouse movement does not acknowledge results.
- Classify blocking requests with `kind=permission`, `kind=question`, or `kind=auth`, including requests hidden behind another view or delayed while typing, independently of terminal title settings.
- Keep pending follow-up work and active goals `working`. Treat interrupted turns and replayed results as `idle` rather than new results.
- Preserve the existing iTerm2 OSC 21337 labels and indicators, mapping `done` and `error` to `Idle`.

## Testing

Add lifecycle and input-acknowledgment tests covering approvals, completion, failure, interruption, replay, pending follow-ups, and active goals. Extend blocking-request assertions and terminal protocol snapshots for the new states and reasons.

GitOrigin-RevId: 90bad1d54d8b3b9e3cec66fbd3bf3df0c42b995b
This commit is contained in:
Channing Conger
2026-10-14 22:58:37 +00:00
committed by copyberry
parent e1e2257e29
commit dec54912f9
20 changed files with 441 additions and 58 deletions
+6
View File
@@ -877,6 +877,12 @@ impl App {
self.discard_startup_modal_input(tui)?;
return Ok(AppRunControl::Continue);
}
if matches!(&event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release)
|| matches!(&event, TuiEvent::Paste(text) if !text.is_empty())
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
{
self.chat_widget.acknowledge_program_status();
}
let idle_draw = matches!(event, TuiEvent::Draw);
if self.handle_rendered_selection_event(tui, &event)? {
return Ok(AppRunControl::Continue);
+58
View File
@@ -275,6 +275,64 @@ async fn pasted_text_normalizes_mixed_line_endings_at_app_boundary() -> Result<(
Ok(())
}
#[tokio::test]
async fn program_status_acknowledges_deliberate_mouse_events() -> Result<()> {
use crate::terminal_program_status::ProgramStatus;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
let mut app = make_test_app().await;
let mut app_server = start_config_write_test_app_server(&app).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let thread_id = ThreadId::new();
for (turn_status, expected) in [
(TurnStatus::Completed, ProgramStatus::Done),
(TurnStatus::Failed, ProgramStatus::Error),
] {
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Drag(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
MouseEventKind::ScrollUp,
MouseEventKind::ScrollDown,
] {
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "result"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
turn_completed_notification(thread_id, "result", turn_status.clone()),
/*replay_kind*/ None,
);
let before = app.chat_widget.desired_program_status();
let mut mouse = MouseEvent {
kind: MouseEventKind::Moved,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
};
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse))
.await?;
let after_hover = app.chat_widget.desired_program_status();
mouse.kind = kind;
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse))
.await?;
assert_eq!(
[
before,
after_hover,
app.chat_widget.desired_program_status()
],
[expected, expected, ProgramStatus::Idle],
"after {turn_status:?}, {kind:?}",
);
}
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn duplicate_named_resume_renders_error_without_replacing_current_session() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -813,6 +813,22 @@ impl BottomPaneView for AppLinkView {
fn terminal_title_requires_action(&self) -> bool {
self.is_tool_suggestion()
}
fn program_status_blocked_kind(
&self,
) -> Option<crate::terminal_program_status::ProgramStatusKind> {
use crate::terminal_program_status::ProgramStatusKind;
if !self.is_tool_suggestion() {
return None;
}
Some(match self.suggestion_type {
Some(AppLinkSuggestionType::Auth) => ProgramStatusKind::Auth,
Some(AppLinkSuggestionType::Install | AppLinkSuggestionType::Enable) => {
ProgramStatusKind::Permission
}
Some(AppLinkSuggestionType::ExternalAction) | None => ProgramStatusKind::Question,
})
}
}
impl crate::render::renderable::Renderable for AppLinkView {
@@ -122,6 +122,18 @@ pub(crate) struct McpElicitationApprovalRequest {
}
impl ApprovalRequest {
pub(super) fn program_status_blocked_kind(
&self,
) -> crate::terminal_program_status::ProgramStatusKind {
use crate::terminal_program_status::ProgramStatusKind;
match self {
Self::Exec(_) | Self::Permissions(_) | Self::ApplyPatch(_) => {
ProgramStatusKind::Permission
}
Self::McpElicitation(_) => ProgramStatusKind::Question,
}
}
pub(super) fn thread_id(&self) -> ThreadId {
match self {
ApprovalRequest::Exec(request) => request.thread_id,
@@ -620,6 +632,14 @@ impl BottomPaneView for ApprovalOverlay {
fn terminal_title_requires_action(&self) -> bool {
true
}
fn program_status_blocked_kind(
&self,
) -> Option<crate::terminal_program_status::ProgramStatusKind> {
self.current_request
.as_ref()
.map(ApprovalRequest::program_status_blocked_kind)
}
}
impl Renderable for ApprovalOverlay {
@@ -4,6 +4,7 @@ use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::key_hint::KeyBinding;
use crate::keymap::KeymapContextSet;
use crate::render::renderable::Renderable;
use crate::terminal_program_status::ProgramStatusKind;
use codex_app_server_protocol::ToolRequestUserInputParams;
use crossterm::event::KeyEvent;
use std::time::Instant;
@@ -229,6 +230,13 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Classify interactive requests that prevent the agent from continuing.
///
/// Ordinary settings and navigation views do not block a running agent.
fn program_status_blocked_kind(&self) -> Option<ProgramStatusKind> {
None
}
/// Return the next time-based redraw this view needs while it is active.
fn next_frame_delay(&self) -> Option<std::time::Duration> {
None
@@ -56,6 +56,7 @@ use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::terminal_program_status::ProgramStatusKind;
use crate::text_formatting::format_json_compact;
use crate::text_formatting::truncate_text;
@@ -1656,6 +1657,13 @@ impl BottomPaneView for McpServerElicitationOverlay {
true
}
fn program_status_blocked_kind(&self) -> Option<ProgramStatusKind> {
Some(match self.request.response_mode {
McpServerElicitationResponseMode::FormContent => ProgramStatusKind::Question,
McpServerElicitationResponseMode::ApprovalAction => ProgramStatusKind::Permission,
})
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if !self.current_field_is_select() && !self.composer.current_text_with_pending().is_empty()
{
@@ -2681,6 +2689,10 @@ mod tests {
/*disable_paste_burst*/ false,
);
assert_eq!(
overlay.program_status_blocked_kind(),
Some(ProgramStatusKind::Question)
);
insta::assert_snapshot!(
"mcp_server_elicitation_boolean_form",
render_snapshot(&overlay, Rect::new(0, 0, 120, 16))
@@ -2708,6 +2720,10 @@ mod tests {
/*disable_paste_burst*/ false,
);
assert_eq!(
overlay.program_status_blocked_kind(),
Some(ProgramStatusKind::Permission)
);
insta::assert_snapshot!(
"mcp_server_elicitation_approval_form_without_schema",
render_snapshot(&overlay, Rect::new(0, 0, 120, 16))
+34
View File
@@ -47,6 +47,7 @@ use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::terminal_palette::effective_stdout_color_level;
use crate::terminal_program_status::ProgramStatusKind;
use crate::tui::FrameRequester;
pub(crate) use bottom_pane_view::BottomPaneView;
pub(crate) use bottom_pane_view::ViewCompletion;
@@ -1823,6 +1824,29 @@ impl BottomPane {
.is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action)
}
/// Include requests behind a picker or delayed while the user is typing.
/// Async questions only block once the agent is no longer running.
pub(crate) fn program_status_blocked_kind(&self) -> Option<ProgramStatusKind> {
self.view_stack
.iter()
.rev()
.filter(|view| !view.is_complete())
.find_map(|view| view.program_status_blocked_kind())
.or_else(|| {
self.delayed_approval_requests
.front()
.map(|delayed| delayed.request.program_status_blocked_kind())
})
.or_else(|| {
(!self.is_task_running()
&& self
.questions
.as_ref()
.is_some_and(|questions| questions.unanswered_count() > 0))
.then_some(ProgramStatusKind::Question)
})
}
pub(crate) fn has_centered_view(&self) -> bool {
self.active_view()
.is_some_and(|view| view.presentation() == ViewPresentation::Centered)
@@ -3023,7 +3047,12 @@ mod tests {
skills: Some(Vec::new()),
});
pane.push_approval_request(exec_request(), &features);
assert_eq!(
pane.program_status_blocked_kind(),
Some(ProgramStatusKind::Permission)
);
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert_eq!(pane.program_status_blocked_kind(), None);
assert!(!pane.quit_shortcut_hint_visible());
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}
@@ -3266,6 +3295,10 @@ mod tests {
let request = exec_request();
let thread_id = request.thread_id();
pane.push_approval_request(request, &features);
assert_eq!(
pane.program_status_blocked_kind(),
Some(ProgramStatusKind::Permission)
);
assert!(
pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
@@ -3274,6 +3307,7 @@ mod tests {
})
);
assert!(pane.delayed_approval_requests.is_empty());
assert_eq!(pane.program_status_blocked_kind(), None);
pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
assert!(pane.view_stack.is_empty());
@@ -1460,6 +1460,12 @@ impl BottomPaneView for RequestUserInputOverlay {
true
}
fn program_status_blocked_kind(
&self,
) -> Option<crate::terminal_program_status::ProgramStatusKind> {
Some(crate::terminal_program_status::ProgramStatusKind::Question)
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.confirm_unanswered_active() {
self.close_unanswered_confirmation();
@@ -218,6 +218,12 @@ impl BottomPaneView for UserVerificationView {
fn terminal_title_requires_action(&self) -> bool {
!self.is_complete()
}
fn program_status_blocked_kind(
&self,
) -> Option<crate::terminal_program_status::ProgramStatusKind> {
(!self.is_complete()).then_some(crate::terminal_program_status::ProgramStatusKind::Auth)
}
}
impl Renderable for UserVerificationView {
@@ -313,6 +313,11 @@ impl ChatWidget {
self.requeue_image_submission();
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn();
// Cancellation means idle in the program-status protocol, not a failed task.
if reason == TurnAbortReason::Interrupted {
self.status_state.program_status_after_turn =
crate::terminal_program_status::ProgramStatus::Idle;
}
let send_pending_steers_immediately =
self.input_queue.submit_pending_steers_after_interrupt;
self.input_queue.submit_pending_steers_after_interrupt = false;
@@ -1,6 +1,7 @@
//! Status indicator and terminal-title state for `ChatWidget`.
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::terminal_program_status::ProgramStatus;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct StatusIndicatorState {
@@ -120,6 +121,9 @@ pub(super) struct StatusState {
pub(super) current_status: StatusIndicatorState,
pub(super) pending_guardian_review_status: PendingGuardianReviewStatus,
pub(super) terminal_title_status_kind: TerminalTitleStatusKind,
/// Result of the latest live turn until the user interacts again. Active
/// work and blocking prompts take precedence over this value.
pub(super) program_status_after_turn: ProgramStatus,
pub(super) retry_status_header: Option<String>,
pub(super) pending_status_indicator_restore: bool,
pub(super) thread_title_generation_pending: bool,
@@ -135,6 +139,7 @@ impl Default for StatusState {
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
program_status_after_turn: ProgramStatus::Idle,
retry_status_header: None,
pending_status_indicator_restore: false,
thread_title_generation_pending: false,
+25 -5
View File
@@ -322,13 +322,33 @@ impl ChatWidget {
self.bottom_pane.terminal_title_requires_action()
}
pub(super) fn desired_program_status(&self) -> ProgramStatus {
if self.terminal_title_requires_action() {
ProgramStatus::Blocked
} else if self.bottom_pane.is_task_running() || !self.unified_exec_processes.is_empty() {
/// Reports the displayed thread's actual state independently of terminal
/// title/notification settings. Replays never produce an unseen result.
pub(crate) fn desired_program_status(&self) -> ProgramStatus {
if let Some(kind) = self.bottom_pane.program_status_blocked_kind() {
ProgramStatus::Blocked(kind)
} else if self.bottom_pane.is_task_running()
|| self.is_user_turn_pending_or_running()
|| !self.unified_exec_processes.is_empty()
|| (self.status_state.program_status_after_turn == ProgramStatus::Done
&& self
.current_goal_status
.as_ref()
.is_some_and(GoalStatusState::is_active))
{
ProgramStatus::Working
} else {
ProgramStatus::Idle
self.status_state.program_status_after_turn
}
}
pub(crate) fn acknowledge_program_status(&mut self) {
if matches!(
self.desired_program_status(),
ProgramStatus::Done | ProgramStatus::Error
) {
self.status_state.program_status_after_turn = ProgramStatus::Idle;
self.request_redraw();
}
}
+2
View File
@@ -275,6 +275,8 @@ mod plan_mode;
#[path = "tests/plugin_catalog_tests.rs"]
mod plugin_catalog;
mod popups_and_settings;
#[path = "tests/program_status.rs"]
mod program_status;
#[path = "tests/rate_limit_recovery_tests.rs"]
mod rate_limit_recovery_tests;
#[path = "tests/reasoning_status_tests.rs"]
@@ -0,0 +1,101 @@
//! Exercises terminal status across live, interrupted, and replayed turns.
//! Replayed results and work already underway must never appear as new completions.
use super::*;
use crate::terminal_program_status::ProgramStatus;
use crate::terminal_program_status::ProgramStatusKind;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn program_status_tracks_live_results_and_resolved_approvals() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut statuses = vec![chat.desired_program_status()];
handle_turn_started(&mut chat, "status-turn");
statuses.push(chat.desired_program_status());
handle_exec_approval_request(
&mut chat,
"status-approval",
ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "status-call".to_string(),
approval_id: Some("status-call".to_string()),
turn_id: "status-turn".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hello".to_string()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some("need confirmation".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
},
);
statuses.push(chat.desired_program_status());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
statuses.push(chat.desired_program_status());
handle_turn_completed(&mut chat, "status-turn", /*duration_ms*/ None);
statuses.push(chat.desired_program_status());
chat.pre_draw_tick();
statuses.push(chat.desired_program_status());
chat.acknowledge_program_status();
statuses.push(chat.desired_program_status());
handle_turn_started(&mut chat, "cancelled-turn");
handle_turn_interrupted(&mut chat, "cancelled-turn");
statuses.push(chat.desired_program_status());
handle_turn_started(&mut chat, "failed-turn");
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: String::new(),
turn: app_server_turn(
"failed-turn",
AppServerTurnStatus::Failed,
/*duration_ms*/ None,
/*error*/ None,
),
}),
/*replay_kind*/ None,
);
statuses.push(chat.desired_program_status());
chat.acknowledge_program_status();
statuses.push(chat.desired_program_status());
assert_eq!(
statuses,
[
ProgramStatus::Idle,
ProgramStatus::Working,
ProgramStatus::Blocked(ProgramStatusKind::Permission),
ProgramStatus::Working,
ProgramStatus::Done,
ProgramStatus::Done,
ProgramStatus::Idle,
ProgramStatus::Idle,
ProgramStatus::Error,
ProgramStatus::Idle,
]
);
}
#[tokio::test]
async fn program_status_does_not_treat_replayed_results_as_new_or_hide_follow_up_work() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "old-turn");
chat.on_task_complete(
/*last_agent_message*/ None, /*completion*/ None, /*from_replay*/ true,
);
let replayed = chat.desired_program_status();
handle_turn_started(&mut chat, "live-turn");
handle_turn_completed(&mut chat, "live-turn", /*duration_ms*/ None);
let completed = chat.desired_program_status();
// A queued follow-up is already submitted, before its TurnStarted notification arrives.
chat.input_queue.user_turn_pending_start = true;
let pending = chat.desired_program_status();
assert_eq!(
[replayed, completed, pending],
[
ProgramStatus::Idle,
ProgramStatus::Done,
ProgramStatus::Working
]
);
}
@@ -2263,6 +2263,37 @@ async fn ctrl_c_interrupt_pauses_active_goal_turn() {
assert_goal_paused_event(&mut rx, thread_id);
}
#[tokio::test]
async fn program_status_stays_working_when_input_acknowledges_an_active_goal() {
use crate::terminal_program_status::ProgramStatus;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = start_active_goal_turn(&mut chat);
let mut statuses = vec![chat.desired_program_status()];
chat.on_task_complete(
/*last_agent_message*/ None, /*completion*/ None, /*from_replay*/ false,
);
statuses.push(chat.desired_program_status());
// App acknowledges input before the composer handles a non-interrupting key.
chat.acknowledge_program_status();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
statuses.push(chat.desired_program_status());
update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Complete);
statuses.push(chat.desired_program_status());
chat.acknowledge_program_status();
statuses.push(chat.desired_program_status());
assert_eq!(
statuses,
[
ProgramStatus::Working,
ProgramStatus::Working,
ProgramStatus::Working,
ProgramStatus::Done,
ProgramStatus::Idle,
]
);
}
#[tokio::test]
async fn esc_interrupt_pauses_active_goal_turn() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -4,6 +4,7 @@ use pretty_assertions::assert_eq;
use crate::terminal_program_status::ProgramStatus::Blocked;
use crate::terminal_program_status::ProgramStatus::Idle;
use crate::terminal_program_status::ProgramStatus::Working;
use crate::terminal_program_status::ProgramStatusKind;
#[tokio::test]
async fn session_status_follows_existing_lifecycle_state() {
@@ -52,7 +53,15 @@ async fn session_status_follows_existing_lifecycle_state() {
assert_eq!(
states,
[Idle, Working, Blocked, Working, Idle, Working, Idle]
[
Idle,
Working,
Blocked(ProgramStatusKind::Permission),
Working,
Idle,
Working,
Idle
]
);
}
@@ -66,7 +75,7 @@ async fn session_status_detail_uses_only_foreground_activity() {
let generic = detail(&chat, Working);
chat.set_status_header("Reading source".to_string());
let working = detail(&chat, Working);
let waiting = detail(&chat, Blocked);
let waiting = detail(&chat, Blocked(ProgramStatusKind::Question));
chat.bottom_pane.set_task_running(/*running*/ false);
let idle = detail(&chat, Idle);
let background = detail(&chat, Working);
@@ -75,6 +75,8 @@ impl ChatWidget {
// Raw reasoning uses the same flow as summarized reasoning
pub(super) fn on_task_started(&mut self) {
self.status_state.program_status_after_turn =
crate::terminal_program_status::ProgramStatus::Idle;
self.bottom_pane.dismiss_composer_sparkle();
self.clear_context_compaction();
self.input_queue.user_turn_pending_start = false;
@@ -187,6 +189,11 @@ impl ChatWidget {
self.clear_active_hook_cell();
self.clear_guardian_review_status();
self.turn_lifecycle.finish();
self.status_state.program_status_after_turn = if from_replay {
crate::terminal_program_status::ProgramStatus::Idle
} else {
crate::terminal_program_status::ProgramStatus::Done
};
self.clear_safety_buffering();
self.update_task_running_state();
self.running_commands.clear();
@@ -347,6 +354,12 @@ impl ChatWidget {
self.input_queue.user_turn_pending_start = false;
self.clear_guardian_review_status();
self.turn_lifecycle.finish();
self.status_state.program_status_after_turn = if self.thread_usage.replaying_turn_completion
{
crate::terminal_program_status::ProgramStatus::Idle
} else {
crate::terminal_program_status::ProgramStatus::Error
};
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
+22 -22
View File
@@ -6,9 +6,10 @@ use codex_terminal_detection::TerminalName;
use crossterm::Command;
use pretty_assertions::assert_eq;
use super::CachedStatus;
use super::ClearTerminalProgramStatus;
use super::NO_EMITTED_STATUS;
use super::ProgramStatus;
use super::ProgramStatusKind;
use super::SessionStatusState;
use super::SetTerminalProgramStatus;
use super::TerminalProgramStatusSupport;
@@ -21,7 +22,11 @@ fn encodes_iterm_session_status_fields() {
for (status, detail) in [
(ProgramStatus::Idle, ""),
(ProgramStatus::Working, "Reading source"),
(ProgramStatus::Blocked, ""),
(ProgramStatus::Blocked(ProgramStatusKind::Permission), ""),
(ProgramStatus::Blocked(ProgramStatusKind::Question), ""),
(ProgramStatus::Blocked(ProgramStatusKind::Auth), ""),
(ProgramStatus::Done, ""),
(ProgramStatus::Error, ""),
] {
let mut value = String::new();
SetTerminalProgramStatus {
@@ -94,53 +99,48 @@ fn clear_empties_every_owned_field() {
fn cache_changes_only_after_a_successful_write() {
let state = SessionStatusState::new();
let mut emitted = Vec::new();
let working = CachedStatus::Reported(ProgramStatus::Working);
state
.update(ProgramStatus::Working as u8, "Thinking", || {
.update(working, "Thinking", || {
Err(io::Error::other("write failed"))
})
.expect_err("failed write should be returned");
state
.update(ProgramStatus::Working as u8, "Thinking", || {
emitted.push(ProgramStatus::Working as u8);
.update(working, "Thinking", || {
emitted.push(working);
Ok(())
})
.expect("retry status write");
state
.update(ProgramStatus::Working as u8, "Thinking", || {
emitted.push(ProgramStatus::Blocked as u8);
.update(working, "Thinking", || {
emitted.push(CachedStatus::Reported(ProgramStatus::Blocked(
ProgramStatusKind::Question,
)));
Ok(())
})
.expect("deduplicate status write");
state
.update(ProgramStatus::Working as u8, "Reading", || {
emitted.push(ProgramStatus::Working as u8);
.update(working, "Reading", || {
emitted.push(working);
Ok(())
})
.expect("emit changed detail");
state.invalidate();
state
.update(ProgramStatus::Working as u8, "Thinking", || {
emitted.push(ProgramStatus::Working as u8);
.update(working, "Thinking", || {
emitted.push(working);
Ok(())
})
.expect("re-emit status after terminal handoff");
state
.update(NO_EMITTED_STATUS, "", || {
emitted.push(NO_EMITTED_STATUS);
.update(CachedStatus::Cleared, "", || {
emitted.push(CachedStatus::Cleared);
Ok(())
})
.expect("clear status");
assert_eq!(
emitted,
[
ProgramStatus::Working as u8,
ProgramStatus::Working as u8,
ProgramStatus::Working as u8,
NO_EMITTED_STATUS,
]
);
assert_eq!(emitted, [working, working, working, CachedStatus::Cleared,]);
}
#[test]
@@ -1,8 +1,12 @@
---
source: tui/src/iterm_session_status_tests.rs
assertion_line: 42
assertion_line: 47
expression: snapshot
---
\u{1b}]21337;status=Idle;indicator=#00d75f;status-color=;detail=\u{7}\u{1b}]7501;state=idle:app=codex\u{1b}\\
\u{1b}]21337;status=Working;indicator=#ff9500;status-color=;detail=Reading source\u{7}\u{1b}]7501;state=working:app=codex\u{1b}\\
\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7}\u{1b}]7501;state=blocked:app=codex\u{1b}\\
\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7}\u{1b}]7501;state=blocked:app=codex:kind=permission\u{1b}\\
\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7}\u{1b}]7501;state=blocked:app=codex:kind=question\u{1b}\\
\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7}\u{1b}]7501;state=blocked:app=codex:kind=auth\u{1b}\\
\u{1b}]21337;status=Idle;indicator=#00d75f;status-color=;detail=\u{7}\u{1b}]7501;state=done:app=codex\u{1b}\\
\u{1b}]21337;status=Idle;indicator=#00d75f;status-color=;detail=\u{7}\u{1b}]7501;state=error:app=codex\u{1b}\\
+50 -27
View File
@@ -1,10 +1,9 @@
//! Emits coarse Codex lifecycle state through terminal status protocols.
//! Emits Codex lifecycle state through terminal status protocols.
//!
//! OSC 7501 is safe to send to any terminal and reports only the lifecycle
//! state and app name. Direct iTerm2 sessions also receive the existing OSC
//! 21337 status, including bounded activity text. A process-wide cache matches
//! the terminal session's ownership and prevents stale state when the active
//! chat changes.
//! OSC 7501 reports lifecycle state and blocking reasons to terminal sessions.
//! Direct iTerm2 sessions also receive OSC 21337 status, including bounded
//! activity text. The process-wide cache is used for exit, panic, and terminal
//! handoff cleanup.
use std::fmt;
use std::io;
@@ -19,8 +18,6 @@ use codex_terminal_detection::terminal_info;
use crossterm::Command;
use ratatui::crossterm::execute;
const NO_EMITTED_STATUS: u8 = 0;
const INVALIDATED_EMITTED_STATUS: u8 = u8::MAX;
static SESSION_STATUS_STATE: SessionStatusState = SessionStatusState::new();
static TERMINAL_PROGRAM_STATUS_SUPPORT: OnceLock<TerminalProgramStatusSupport> = OnceLock::new();
@@ -29,15 +26,22 @@ struct SessionStatusState {
}
struct EmittedSessionStatus {
status: u8,
status: CachedStatus,
detail: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CachedStatus {
Cleared,
Reported(ProgramStatus),
Invalidated,
}
impl SessionStatusState {
const fn new() -> Self {
Self {
last_emitted: Mutex::new(EmittedSessionStatus {
status: NO_EMITTED_STATUS,
status: CachedStatus::Cleared,
detail: String::new(),
}),
}
@@ -45,7 +49,7 @@ impl SessionStatusState {
fn update(
&self,
status: u8,
status: CachedStatus,
detail: &str,
emit: impl FnOnce() -> io::Result<()>,
) -> io::Result<()> {
@@ -69,8 +73,8 @@ impl SessionStatusState {
.last_emitted
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if last_emitted.status != NO_EMITTED_STATUS {
last_emitted.status = INVALIDATED_EMITTED_STATUS;
if last_emitted.status != CachedStatus::Cleared {
last_emitted.status = CachedStatus::Invalidated;
}
}
}
@@ -82,27 +86,35 @@ struct TerminalProgramStatusSupport {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub(crate) enum ProgramStatus {
Idle = 1,
Working = 2,
Blocked = 3,
Idle,
Working,
Blocked(ProgramStatusKind),
Done,
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProgramStatusKind {
Permission,
Question,
Auth,
}
impl ProgramStatus {
fn iterm_label(self) -> &'static str {
match self {
Self::Idle => "Idle",
Self::Idle | Self::Done | Self::Error => "Idle",
Self::Working => "Working",
Self::Blocked => "Waiting",
Self::Blocked(_) => "Waiting",
}
}
fn iterm_indicator(self) -> &'static str {
match self {
Self::Idle => "#00d75f",
Self::Idle | Self::Done | Self::Error => "#00d75f",
Self::Working => "#ff9500",
Self::Blocked => "#5f87ff",
Self::Blocked(_) => "#5f87ff",
}
}
@@ -110,7 +122,9 @@ impl ProgramStatus {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::Blocked => "blocked",
Self::Blocked(_) => "blocked",
Self::Done => "done",
Self::Error => "error",
}
}
}
@@ -133,7 +147,7 @@ pub(crate) fn set_terminal_program_status(
} else {
String::new()
};
SESSION_STATUS_STATE.update(status as u8, &detail, || {
SESSION_STATUS_STATE.update(CachedStatus::Reported(status), &detail, || {
execute!(
stdout(),
SetTerminalProgramStatus {
@@ -155,7 +169,7 @@ pub(crate) fn clear_terminal_program_status() -> io::Result<()> {
return Ok(());
}
SESSION_STATUS_STATE.update(NO_EMITTED_STATUS, "", || {
SESSION_STATUS_STATE.update(CachedStatus::Cleared, "", || {
execute!(
stdout(),
ClearTerminalProgramStatus {
@@ -233,9 +247,18 @@ impl Command for SetTerminalProgramStatus<'_> {
}
write!(
f,
"\x1b]7501;state={}:app=codex\x1b\\",
self.status.protocol_state(),
)
"\x1b]7501;state={}:app=codex",
self.status.protocol_state()
)?;
if let ProgramStatus::Blocked(kind) = self.status {
let kind = match kind {
ProgramStatusKind::Permission => "permission",
ProgramStatusKind::Question => "question",
ProgramStatusKind::Auth => "auth",
};
write!(f, ":kind={kind}")?;
}
write!(f, "\x1b\\")
}
#[cfg(windows)]