From dec54912f9dbd307ebbe8beeb3509a6da30dbf96 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Wed, 14 Oct 2026 22:50:11 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/app.rs | 6 ++ codex-rs/tui/src/app/tests.rs | 58 ++++++++++ codex-rs/tui/src/bottom_pane/app_link_view.rs | 16 +++ .../tui/src/bottom_pane/approval_overlay.rs | 20 ++++ .../tui/src/bottom_pane/bottom_pane_view.rs | 8 ++ .../src/bottom_pane/mcp_server_elicitation.rs | 16 +++ codex-rs/tui/src/bottom_pane/mod.rs | 34 ++++++ .../src/bottom_pane/request_user_input/mod.rs | 6 ++ .../tui/src/bottom_pane/user_verification.rs | 6 ++ codex-rs/tui/src/chatwidget/input_restore.rs | 5 + codex-rs/tui/src/chatwidget/status_state.rs | 5 + .../tui/src/chatwidget/status_surfaces.rs | 30 +++++- codex-rs/tui/src/chatwidget/tests.rs | 2 + .../src/chatwidget/tests/program_status.rs | 101 ++++++++++++++++++ .../src/chatwidget/tests/status_and_layout.rs | 31 ++++++ .../tests/terminal_program_status.rs | 13 ++- codex-rs/tui/src/chatwidget/turn_runtime.rs | 13 +++ .../tui/src/iterm_session_status_tests.rs | 44 ++++---- ...s__tests__iterm_session_status_fields.snap | 8 +- codex-rs/tui/src/terminal_program_status.rs | 77 ++++++++----- 20 files changed, 441 insertions(+), 58 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/program_status.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c8b40454cd..127651bd28 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6ffec87971..6c8b9a4965 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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; diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index dcfdf6a9fa..e78b2ff249 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -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 { + 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 { diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 6ab14d1f68..0fee868894 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -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 { + self.current_request + .as_ref() + .map(ApprovalRequest::program_status_blocked_kind) + } } impl Renderable for ApprovalOverlay { diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6e3726352a..57a66255c5 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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 { + None + } + /// Return the next time-based redraw this view needs while it is active. fn next_frame_delay(&self) -> Option { None diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 39488bca1f..78f7dcf0fa 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -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 { + 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)) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3544f57a4e..3a930b9a55 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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 { + 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()); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 51dab27b64..957d0c089b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1460,6 +1460,12 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn program_status_blocked_kind( + &self, + ) -> Option { + Some(crate::terminal_program_status::ProgramStatusKind::Question) + } + fn on_ctrl_c(&mut self) -> CancellationEvent { if self.confirm_unanswered_active() { self.close_unanswered_confirmation(); diff --git a/codex-rs/tui/src/bottom_pane/user_verification.rs b/codex-rs/tui/src/bottom_pane/user_verification.rs index 612e1c4b4f..8edae9bf61 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification.rs @@ -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 { + (!self.is_complete()).then_some(crate::terminal_program_status::ProgramStatusKind::Auth) + } } impl Renderable for UserVerificationView { diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index e1a798b522..086aa4ac1e 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/status_state.rs b/codex-rs/tui/src/chatwidget/status_state.rs index 5fe1eba3da..3eeb65d464 100644 --- a/codex-rs/tui/src/chatwidget/status_state.rs +++ b/codex-rs/tui/src/chatwidget/status_state.rs @@ -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, 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, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index a3946abd13..d7a5c3bb64 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -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(); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8534d753f6..ee79aeffb4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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"] diff --git a/codex-rs/tui/src/chatwidget/tests/program_status.rs b/codex-rs/tui/src/chatwidget/tests/program_status.rs new file mode 100644 index 0000000000..a79994155c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/program_status.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 + ] + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 301b55c2cc..760f72edc7 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs b/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs index 3911129ba3..2a2df10cc0 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs @@ -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); diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 2b394230f8..d9726d2fc4 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -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(); diff --git a/codex-rs/tui/src/iterm_session_status_tests.rs b/codex-rs/tui/src/iterm_session_status_tests.rs index 12f35136f6..7612916829 100644 --- a/codex-rs/tui/src/iterm_session_status_tests.rs +++ b/codex-rs/tui/src/iterm_session_status_tests.rs @@ -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] diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap index 07001e76a5..f8b331d1ef 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap @@ -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}\\ diff --git a/codex-rs/tui/src/terminal_program_status.rs b/codex-rs/tui/src/terminal_program_status.rs index 9379f960a6..427b47d81d 100644 --- a/codex-rs/tui/src/terminal_program_status.rs +++ b/codex-rs/tui/src/terminal_program_status.rs @@ -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 = 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)]