From 4acb456bfebcd6c391230cd70a4f66ec23f0a1b2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 21 May 2026 10:45:43 -0700 Subject: [PATCH] TUI: skip goal replace prompt for completed goals (#23792) ## Why Users reported that the replacement confirmation feels unnecessary when the current thread goal is already complete. In that state, `/goal ` is starting fresh rather than interrupting active work. ## What changed `/goal ` now skips the replace confirmation when the existing goal has `complete` status and uses the existing fresh replacement path. Goals that are active, paused, blocked, usage-limited, or budget-limited still require confirmation before being replaced. --- codex-rs/tui/src/app/thread_goal_actions.rs | 66 ++++++++++++++++++--- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index b9115ac283..5911801755 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::goal_display::goal_status_label; use crate::goal_display::goal_usage_summary; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalStatus; use codex_protocol::ThreadId; @@ -116,25 +117,30 @@ impl App { objective: String, mode: ThreadGoalSetMode, ) { - if matches!(mode, ThreadGoalSetMode::ConfirmIfExists) { + let mode = if matches!(mode, ThreadGoalSetMode::ConfirmIfExists) { let result = app_server.thread_goal_get(thread_id).await; if self.current_displayed_thread_id() != Some(thread_id) { return; } match result { - Ok(response) if response.goal.is_some() => { - self.show_replace_thread_goal_confirmation(thread_id, objective); - return; - } - Ok(_) => {} + Ok(response) => match response.goal.as_ref() { + Some(goal) if should_confirm_before_replacing_goal(goal) => { + self.show_replace_thread_goal_confirmation(thread_id, objective); + return; + } + Some(_) => ThreadGoalSetMode::ReplaceExisting, + None => mode, + }, Err(err) => { self.chat_widget .add_error_message(thread_goal_error_message("read", &err)); return; } } - } + } else { + mode + }; let replacing_goal = matches!(mode, ThreadGoalSetMode::ReplaceExisting); if replacing_goal { @@ -296,6 +302,19 @@ fn is_ephemeral_thread_goal_error(err: &color_eyre::Report) -> bool { }) } +fn should_confirm_before_replacing_goal(goal: &ThreadGoal) -> bool { + // Completed goals are terminal, so `/goal ` can start a fresh goal + // without asking the user to confirm replacing already-finished work. + match goal.status { + ThreadGoalStatus::Complete => false, + ThreadGoalStatus::Active + | ThreadGoalStatus::Paused + | ThreadGoalStatus::Blocked + | ThreadGoalStatus::UsageLimited + | ThreadGoalStatus::BudgetLimited => true, + } +} + #[cfg(test)] mod tests { use crate::history_cell::HistoryCell; @@ -350,4 +369,37 @@ mod tests { "Failed to read thread goal: thread/goal/get failed in TUI" ); } + + #[test] + fn completed_goal_does_not_require_replace_confirmation() { + assert!(!should_confirm_before_replacing_goal(&test_goal( + ThreadGoalStatus::Complete + ))); + } + + #[test] + fn unfinished_goals_require_replace_confirmation() { + for status in [ + ThreadGoalStatus::Active, + ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited, + ThreadGoalStatus::BudgetLimited, + ] { + assert!(should_confirm_before_replacing_goal(&test_goal(status))); + } + } + + fn test_goal(status: ThreadGoalStatus) -> ThreadGoal { + ThreadGoal { + thread_id: ThreadId::new().to_string(), + objective: "Finish the thing.".to_string(), + status, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1_776_272_400, + updated_at: 1_776_272_460, + } + } }