mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
committed by
copyberry
parent
e1e2257e29
commit
dec54912f9
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
+6
-2
@@ -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}\\
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user