mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add goal TUI UX (5 / 5) (#18077)
Adds the TUI user experience for goals on top of the core runtime from PR 4. ## Why Users need a direct TUI control surface for long-running goals. The UI should make the current goal visible, support common goal actions without waiting for a model turn, and avoid confusing end-of-turn notifications while an active goal is immediately continuing. ## What changed - Added `/goal` summary rendering for the current goal, including active, paused, budget-limited, and complete states. - Added `/goal <objective>` creation/replacement through the app-server goal API rather than a model prompt. - Added `/goal clear`, `/goal pause`, and `/goal unpause` command variants. - Added a confirmation menu when the user enters a new goal while another goal already exists. - Updated `/goal` help and summary tip text so it reflects the supported command variants without advertising slash-command token budgets. - Added footer/statusline goal indicators, including elapsed time and token budget display when a budget exists from API/tool-created goals. - Consumes goal updated/cleared notifications so the TUI stays in sync with external app-server changes. - Suppresses end-of-turn desktop notifications only when a goal is still active and follow-up work is expected. - Preserves slash-command history behavior and avoids leaking queued `/goal` state into unrelated submissions. ## Verification - Added TUI unit and snapshot coverage for goal command availability, summary rendering, control commands, replacement menu behavior, status/footer display, notification handling, and command history.
This commit is contained in:
@@ -471,6 +471,24 @@ impl App {
|
||||
AppEvent::RefreshRateLimits { origin } => {
|
||||
self.refresh_rate_limits(app_server, origin);
|
||||
}
|
||||
AppEvent::OpenThreadGoalMenu { thread_id } => {
|
||||
self.open_thread_goal_menu(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective,
|
||||
mode,
|
||||
} => {
|
||||
self.set_thread_goal_objective(app_server, thread_id, objective, mode)
|
||||
.await;
|
||||
}
|
||||
AppEvent::SetThreadGoalStatus { thread_id, status } => {
|
||||
self.set_thread_goal_status(app_server, thread_id, status)
|
||||
.await;
|
||||
}
|
||||
AppEvent::ClearThreadGoal { thread_id } => {
|
||||
self.clear_thread_goal(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::SendAddCreditsNudgeEmail { credit_type } => {
|
||||
if self
|
||||
.chat_widget
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use super::App;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ThreadGoalSetMode;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
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::ThreadGoalStatus;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn open_thread_goal_menu(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
let result = app_server.thread_goal_get(thread_id).await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to read thread goal: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(goal) = response.goal else {
|
||||
self.chat_widget.add_info_message(
|
||||
"Usage: /goal <objective>".to_string(),
|
||||
Some("No goal is currently set.".to_string()),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget.show_goal_summary(goal);
|
||||
}
|
||||
|
||||
pub(super) async fn set_thread_goal_objective(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
objective: String,
|
||||
mode: ThreadGoalSetMode,
|
||||
) {
|
||||
if 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(_) => {}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to read thread goal: {err}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = app_server
|
||||
.thread_goal_set(
|
||||
thread_id,
|
||||
Some(objective),
|
||||
Some(ThreadGoalStatus::Active),
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => self.chat_widget.add_info_message(
|
||||
format!("Goal {}", goal_status_label(response.goal.status)),
|
||||
Some(goal_usage_summary(&response.goal)),
|
||||
),
|
||||
Err(err) => self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Failed to set thread goal: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn set_thread_goal_status(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
status: ThreadGoalStatus,
|
||||
) {
|
||||
let result = app_server
|
||||
.thread_goal_set(
|
||||
thread_id,
|
||||
/*objective*/ None,
|
||||
Some(status),
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => self.chat_widget.add_info_message(
|
||||
format!("Goal {}", goal_status_label(response.goal.status)),
|
||||
Some(goal_usage_summary(&response.goal)),
|
||||
),
|
||||
Err(err) => self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Failed to update thread goal: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn clear_thread_goal(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
let result = app_server.thread_goal_clear(thread_id).await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if response.cleared {
|
||||
self.chat_widget
|
||||
.add_info_message("Goal cleared".to_string(), /*hint*/ None);
|
||||
} else {
|
||||
self.chat_widget.add_info_message(
|
||||
"No goal to clear".to_string(),
|
||||
Some("This thread does not currently have a goal.".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Failed to clear thread goal: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn show_replace_thread_goal_confirmation(&mut self, thread_id: ThreadId, objective: String) {
|
||||
let replace_objective = objective.clone();
|
||||
let replace_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective: replace_objective.clone(),
|
||||
mode: ThreadGoalSetMode::ReplaceExisting,
|
||||
});
|
||||
})];
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Replace current goal".to_string(),
|
||||
description: Some("Set the new objective and start it now".to_string()),
|
||||
actions: replace_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Keep the current goal".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
self.chat_widget.show_selection_view(SelectionViewParams {
|
||||
title: Some("Replace goal?".to_string()),
|
||||
subtitle: Some(format!("New objective: {objective}")),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user