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:
committed by
GitHub
Unverified
parent
4167628622
commit
f1c963d77e
@@ -194,6 +194,7 @@ mod session_lifecycle;
|
||||
mod side;
|
||||
mod startup_prompts;
|
||||
mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
mod thread_routing;
|
||||
mod thread_session_state;
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
@@ -54,6 +55,12 @@ pub(crate) enum RealtimeAudioDeviceKind {
|
||||
Speaker,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadGoalSetMode {
|
||||
ConfirmIfExists,
|
||||
ReplaceExisting,
|
||||
}
|
||||
|
||||
impl RealtimeAudioDeviceKind {
|
||||
pub(crate) fn title(self) -> &'static str {
|
||||
match self {
|
||||
@@ -187,6 +194,29 @@ pub(crate) enum AppEvent {
|
||||
origin: RateLimitRefreshOrigin,
|
||||
},
|
||||
|
||||
/// Open the current thread goal summary/action menu.
|
||||
OpenThreadGoalMenu {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
|
||||
/// Set or replace the current thread goal objective.
|
||||
SetThreadGoalObjective {
|
||||
thread_id: ThreadId,
|
||||
objective: String,
|
||||
mode: ThreadGoalSetMode,
|
||||
},
|
||||
|
||||
/// Pause or unpause the current thread goal.
|
||||
SetThreadGoalStatus {
|
||||
thread_id: ThreadId,
|
||||
status: ThreadGoalStatus,
|
||||
},
|
||||
|
||||
/// Clear the current thread goal.
|
||||
ClearThreadGoal {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
|
||||
/// Result of refreshing rate limits.
|
||||
RateLimitsLoaded {
|
||||
origin: RateLimitRefreshOrigin,
|
||||
|
||||
@@ -43,6 +43,13 @@ use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartResponse;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadGoalClearParams;
|
||||
use codex_app_server_protocol::ThreadGoalClearResponse;
|
||||
use codex_app_server_protocol::ThreadGoalGetParams;
|
||||
use codex_app_server_protocol::ThreadGoalGetResponse;
|
||||
use codex_app_server_protocol::ThreadGoalSetParams;
|
||||
use codex_app_server_protocol::ThreadGoalSetResponse;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_app_server_protocol::ThreadInjectItemsParams;
|
||||
use codex_app_server_protocol::ThreadInjectItemsResponse;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
@@ -667,6 +674,60 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_goal_get(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<ThreadGoalGetResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::ThreadGoalGet {
|
||||
request_id,
|
||||
params: ThreadGoalGetParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/goal/get failed in TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_goal_set(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
objective: Option<String>,
|
||||
status: Option<ThreadGoalStatus>,
|
||||
token_budget: Option<Option<i64>>,
|
||||
) -> Result<ThreadGoalSetResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::ThreadGoalSet {
|
||||
request_id,
|
||||
params: ThreadGoalSetParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
objective,
|
||||
status,
|
||||
token_budget,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/goal/set failed in TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_goal_clear(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<ThreadGoalClearResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::ThreadGoalClear {
|
||||
request_id,
|
||||
params: ThreadGoalClearParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/goal/clear failed in TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn logout_account(&mut self) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: LogoutAccountResponse = self
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
//!
|
||||
use crate::bottom_pane::footer::goal_status_indicator_line;
|
||||
use crate::bottom_pane::footer::mode_indicator_line;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
@@ -156,6 +157,7 @@ use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::GoalStatusIndicator;
|
||||
use super::footer::SummaryLeft;
|
||||
use super::footer::can_show_left_with_context;
|
||||
use super::footer::context_window_line;
|
||||
@@ -371,9 +373,11 @@ pub(crate) struct ChatComposer {
|
||||
collaboration_modes_enabled: bool,
|
||||
config: ChatComposerConfig,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
connectors_enabled: bool,
|
||||
plugins_command_enabled: bool,
|
||||
fast_command_enabled: bool,
|
||||
goal_command_enabled: bool,
|
||||
personality_command_enabled: bool,
|
||||
realtime_conversation_enabled: bool,
|
||||
audio_device_selection_enabled: bool,
|
||||
@@ -427,6 +431,15 @@ enum SlashValidation {
|
||||
|
||||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||||
|
||||
fn status_line_right_indicator(
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
goal_status_indicator: Option<&GoalStatusIndicator>,
|
||||
show_cycle_hint: bool,
|
||||
) -> Option<Line<'static>> {
|
||||
mode_indicator_line(collaboration_mode_indicator, show_cycle_hint)
|
||||
.or_else(|| goal_status_indicator_line(goal_status_indicator))
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
|
||||
BuiltinCommandFlags {
|
||||
@@ -434,6 +447,7 @@ impl ChatComposer {
|
||||
connectors_enabled: self.connectors_enabled,
|
||||
plugins_command_enabled: self.plugins_command_enabled,
|
||||
fast_command_enabled: self.fast_command_enabled,
|
||||
goal_command_enabled: self.goal_command_enabled,
|
||||
personality_command_enabled: self.personality_command_enabled,
|
||||
realtime_conversation_enabled: self.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: self.audio_device_selection_enabled,
|
||||
@@ -516,9 +530,11 @@ impl ChatComposer {
|
||||
collaboration_modes_enabled: false,
|
||||
config,
|
||||
collaboration_mode_indicator: None,
|
||||
goal_status_indicator: None,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
@@ -606,6 +622,10 @@ impl ChatComposer {
|
||||
self.fast_command_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
|
||||
self.goal_command_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
@@ -613,6 +633,10 @@ impl ChatComposer {
|
||||
self.collaboration_mode_indicator = indicator;
|
||||
}
|
||||
|
||||
pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) {
|
||||
self.goal_status_indicator = indicator;
|
||||
}
|
||||
|
||||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||||
self.personality_command_enabled = enabled;
|
||||
}
|
||||
@@ -3475,6 +3499,7 @@ impl ChatComposer {
|
||||
let connectors_enabled = self.connectors_enabled;
|
||||
let plugins_command_enabled = self.plugins_command_enabled;
|
||||
let fast_command_enabled = self.fast_command_enabled;
|
||||
let goal_command_enabled = self.goal_command_enabled;
|
||||
let personality_command_enabled = self.personality_command_enabled;
|
||||
let realtime_conversation_enabled = self.realtime_conversation_enabled;
|
||||
let audio_device_selection_enabled = self.audio_device_selection_enabled;
|
||||
@@ -3483,6 +3508,7 @@ impl ChatComposer {
|
||||
connectors_enabled,
|
||||
plugins_command_enabled,
|
||||
fast_command_enabled,
|
||||
goal_command_enabled,
|
||||
personality_command_enabled,
|
||||
realtime_conversation_enabled,
|
||||
audio_device_selection_enabled,
|
||||
@@ -3963,31 +3989,34 @@ impl ChatComposer {
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let right_line = if let Some(label) =
|
||||
self.side_conversation_context_label.as_ref()
|
||||
{
|
||||
Some(side_conversation_context_line(label))
|
||||
} else if let Some(line) = self.shell_mode_footer_line() {
|
||||
Some(line)
|
||||
} else if status_line_active {
|
||||
let full =
|
||||
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(
|
||||
self.collaboration_mode_indicator,
|
||||
/*show_cycle_hint*/ false,
|
||||
);
|
||||
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if can_show_left_with_context(hint_rect, left_width, full_width) {
|
||||
full
|
||||
let right_line =
|
||||
if let Some(label) = self.side_conversation_context_label.as_ref() {
|
||||
Some(side_conversation_context_line(label))
|
||||
} else if let Some(line) = self.shell_mode_footer_line() {
|
||||
Some(line)
|
||||
} else if status_line_active {
|
||||
let full = status_line_right_indicator(
|
||||
self.collaboration_mode_indicator,
|
||||
self.goal_status_indicator.as_ref(),
|
||||
show_cycle_hint,
|
||||
);
|
||||
let compact = status_line_right_indicator(
|
||||
self.collaboration_mode_indicator,
|
||||
self.goal_status_indicator.as_ref(),
|
||||
/*show_cycle_hint*/ false,
|
||||
);
|
||||
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if can_show_left_with_context(hint_rect, left_width, full_width) {
|
||||
full
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
} else {
|
||||
Some(context_window_line(
|
||||
footer_props.context_window_percent,
|
||||
footer_props.context_window_used_tokens,
|
||||
))
|
||||
};
|
||||
Some(context_window_line(
|
||||
footer_props.context_window_percent,
|
||||
footer_props.context_window_used_tokens,
|
||||
))
|
||||
};
|
||||
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if status_line_active
|
||||
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
|
||||
|
||||
@@ -34,6 +34,7 @@ pub(crate) struct CommandPopupFlags {
|
||||
pub(crate) connectors_enabled: bool,
|
||||
pub(crate) plugins_command_enabled: bool,
|
||||
pub(crate) fast_command_enabled: bool,
|
||||
pub(crate) goal_command_enabled: bool,
|
||||
pub(crate) personality_command_enabled: bool,
|
||||
pub(crate) realtime_conversation_enabled: bool,
|
||||
pub(crate) audio_device_selection_enabled: bool,
|
||||
@@ -48,6 +49,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
|
||||
connectors_enabled: value.connectors_enabled,
|
||||
plugins_command_enabled: value.plugins_command_enabled,
|
||||
fast_command_enabled: value.fast_command_enabled,
|
||||
goal_command_enabled: value.goal_command_enabled,
|
||||
personality_command_enabled: value.personality_command_enabled,
|
||||
realtime_conversation_enabled: value.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: value.audio_device_selection_enabled,
|
||||
@@ -357,6 +359,7 @@ mod tests {
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
@@ -378,6 +381,7 @@ mod tests {
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
@@ -399,6 +403,7 @@ mod tests {
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
@@ -427,6 +432,7 @@ mod tests {
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
@@ -448,6 +454,7 @@ mod tests {
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
|
||||
@@ -95,6 +95,14 @@ pub(crate) enum CollaborationModeIndicator {
|
||||
Execute,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum GoalStatusIndicator {
|
||||
Active { usage: Option<String> },
|
||||
Paused,
|
||||
BudgetLimited { usage: Option<String> },
|
||||
Complete { usage: Option<String> },
|
||||
}
|
||||
|
||||
const MODE_CYCLE_HINT: &str = "shift+tab to cycle";
|
||||
const FOOTER_CONTEXT_GAP_COLS: u16 = 1;
|
||||
|
||||
@@ -483,6 +491,38 @@ pub(crate) fn mode_indicator_line(
|
||||
indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)]))
|
||||
}
|
||||
|
||||
pub(crate) fn goal_status_indicator_line(
|
||||
indicator: Option<&GoalStatusIndicator>,
|
||||
) -> Option<Line<'static>> {
|
||||
let indicator = indicator?;
|
||||
let label = match indicator {
|
||||
GoalStatusIndicator::Active { usage } => {
|
||||
if let Some(usage) = usage {
|
||||
format!("Pursuing goal ({usage})")
|
||||
} else {
|
||||
"Pursuing goal".to_string()
|
||||
}
|
||||
}
|
||||
GoalStatusIndicator::Paused => "Goal paused (/goal to unpause)".to_string(),
|
||||
GoalStatusIndicator::BudgetLimited { usage } => {
|
||||
if let Some(usage) = usage {
|
||||
format!("Goal unmet ({usage})")
|
||||
} else {
|
||||
"Goal abandoned".to_string()
|
||||
}
|
||||
}
|
||||
GoalStatusIndicator::Complete { usage } => {
|
||||
if let Some(usage) = usage {
|
||||
format!("Goal achieved ({usage})")
|
||||
} else {
|
||||
"Goal achieved".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(Line::from(vec![Span::from(label).magenta()]))
|
||||
}
|
||||
|
||||
pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
|
||||
if let Some(rest) = label.strip_prefix("Side ") {
|
||||
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
|
||||
|
||||
@@ -90,6 +90,9 @@ mod skill_popup;
|
||||
mod skills_toggle_view;
|
||||
pub(crate) mod slash_commands;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use footer::GoalStatusIndicator;
|
||||
#[cfg(test)]
|
||||
pub(crate) use footer::goal_status_indicator_line;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
pub(crate) use list_selection_view::SelectionRowDisplay;
|
||||
pub(crate) use list_selection_view::SelectionToggle;
|
||||
@@ -332,6 +335,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) {
|
||||
self.composer.set_goal_status_indicator(indicator);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_personality_command_enabled(enabled);
|
||||
self.request_redraw();
|
||||
@@ -342,6 +350,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_goal_command_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_realtime_conversation_enabled(enabled);
|
||||
self.request_redraw();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) struct BuiltinCommandFlags {
|
||||
pub(crate) connectors_enabled: bool,
|
||||
pub(crate) plugins_command_enabled: bool,
|
||||
pub(crate) fast_command_enabled: bool,
|
||||
pub(crate) goal_command_enabled: bool,
|
||||
pub(crate) personality_command_enabled: bool,
|
||||
pub(crate) realtime_conversation_enabled: bool,
|
||||
pub(crate) audio_device_selection_enabled: bool,
|
||||
@@ -35,6 +36,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
|
||||
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
|
||||
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
|
||||
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
|
||||
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
|
||||
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
|
||||
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
|
||||
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
|
||||
@@ -75,6 +77,7 @@ mod tests {
|
||||
connectors_enabled: true,
|
||||
plugins_command_enabled: true,
|
||||
fast_command_enabled: true,
|
||||
goal_command_enabled: true,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: true,
|
||||
@@ -120,6 +123,13 @@ mod tests {
|
||||
assert_eq!(find_builtin_command("fast", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_command_is_hidden_when_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
flags.goal_command_enabled = false;
|
||||
assert_eq!(find_builtin_command("goal", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_command_is_hidden_when_realtime_is_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
|
||||
+681
-150
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
//! Goal summary for the bare `/goal` command.
|
||||
|
||||
use super::*;
|
||||
use crate::goal_display::format_goal_elapsed_seconds;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn show_goal_summary(&mut self, goal: AppThreadGoal) {
|
||||
self.add_plain_history_lines(goal_summary_lines(&goal));
|
||||
}
|
||||
|
||||
pub(crate) fn on_thread_goal_cleared(&mut self, thread_id: &str) {
|
||||
if self
|
||||
.thread_id
|
||||
.is_some_and(|active_thread_id| active_thread_id.to_string() == thread_id)
|
||||
{
|
||||
self.current_goal_status = None;
|
||||
self.update_collaboration_mode_indicator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![
|
||||
Line::from("Goal".bold()),
|
||||
Line::from(vec![
|
||||
"Status: ".dim(),
|
||||
goal_status_label(goal.status).to_string().into(),
|
||||
]),
|
||||
Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]),
|
||||
Line::from(vec![
|
||||
"Time used: ".dim(),
|
||||
format_goal_elapsed_seconds(goal.time_used_seconds).into(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
"Tokens used: ".dim(),
|
||||
format_tokens_compact(goal.tokens_used).into(),
|
||||
]),
|
||||
];
|
||||
if let Some(token_budget) = goal.token_budget {
|
||||
lines.push(Line::from(vec![
|
||||
"Token budget: ".dim(),
|
||||
format_tokens_compact(token_budget).into(),
|
||||
]));
|
||||
}
|
||||
let command_hint = match goal.status {
|
||||
AppThreadGoalStatus::Active => "Commands: /goal pause, /goal clear",
|
||||
AppThreadGoalStatus::Paused => "Commands: /goal unpause, /goal clear",
|
||||
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
|
||||
"Commands: /goal clear"
|
||||
}
|
||||
};
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(command_hint.dim()));
|
||||
lines
|
||||
}
|
||||
|
||||
fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
|
||||
match status {
|
||||
AppThreadGoalStatus::Active => "active",
|
||||
AppThreadGoalStatus::Paused => "paused",
|
||||
AppThreadGoalStatus::BudgetLimited => "limited by budget",
|
||||
AppThreadGoalStatus::Complete => "complete",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Helpers for mapping thread-goal state into the compact status-line indicator.
|
||||
|
||||
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::bottom_pane::GoalStatusIndicator;
|
||||
use crate::goal_display::format_goal_elapsed_seconds;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct GoalStatusState {
|
||||
goal: AppThreadGoal,
|
||||
observed_at: Instant,
|
||||
}
|
||||
|
||||
impl GoalStatusState {
|
||||
pub(super) fn new(goal: AppThreadGoal, observed_at: Instant) -> Self {
|
||||
Self { goal, observed_at }
|
||||
}
|
||||
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
self.goal.status == AppThreadGoalStatus::Active
|
||||
}
|
||||
|
||||
pub(super) fn indicator(
|
||||
&self,
|
||||
now: Instant,
|
||||
active_turn_started_at: Option<Instant>,
|
||||
) -> Option<GoalStatusIndicator> {
|
||||
let mut goal = self.goal.clone();
|
||||
if goal.status == AppThreadGoalStatus::Active
|
||||
&& let Some(active_turn_started_at) = active_turn_started_at
|
||||
{
|
||||
let baseline = self.observed_at.max(active_turn_started_at);
|
||||
let active_seconds = now.saturating_duration_since(baseline).as_secs();
|
||||
goal.time_used_seconds = goal
|
||||
.time_used_seconds
|
||||
.saturating_add(i64::try_from(active_seconds).unwrap_or(i64::MAX));
|
||||
}
|
||||
goal_status_indicator_from_app_goal(&goal)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn goal_status_indicator_from_app_goal(
|
||||
goal: &AppThreadGoal,
|
||||
) -> Option<GoalStatusIndicator> {
|
||||
match goal.status {
|
||||
AppThreadGoalStatus::Active => Some(GoalStatusIndicator::Active {
|
||||
usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds),
|
||||
}),
|
||||
AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused),
|
||||
AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited {
|
||||
usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used),
|
||||
}),
|
||||
AppThreadGoalStatus::Complete => Some(GoalStatusIndicator::Complete {
|
||||
usage: Some(completed_goal_usage(
|
||||
goal.token_budget,
|
||||
goal.tokens_used,
|
||||
goal.time_used_seconds,
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn active_goal_usage(
|
||||
token_budget: Option<i64>,
|
||||
tokens_used: i64,
|
||||
time_used_seconds: i64,
|
||||
) -> Option<String> {
|
||||
if let Some(token_budget) = token_budget {
|
||||
return Some(format!(
|
||||
"{} / {}",
|
||||
format_tokens_compact(tokens_used),
|
||||
format_tokens_compact(token_budget)
|
||||
));
|
||||
}
|
||||
|
||||
Some(format_goal_elapsed_seconds(time_used_seconds))
|
||||
}
|
||||
|
||||
fn stopped_goal_budget_usage(token_budget: Option<i64>, tokens_used: i64) -> Option<String> {
|
||||
token_budget.map(|token_budget| {
|
||||
format!(
|
||||
"{} / {} tokens",
|
||||
format_tokens_compact(tokens_used),
|
||||
format_tokens_compact(token_budget)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn completed_goal_usage(
|
||||
token_budget: Option<i64>,
|
||||
tokens_used: i64,
|
||||
time_used_seconds: i64,
|
||||
) -> String {
|
||||
if token_budget.is_some() {
|
||||
return format!("{} tokens", format_tokens_compact(tokens_used));
|
||||
}
|
||||
|
||||
format_goal_elapsed_seconds(time_used_seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GoalStatusState;
|
||||
use super::active_goal_usage;
|
||||
use super::completed_goal_usage;
|
||||
use super::stopped_goal_budget_usage;
|
||||
use crate::bottom_pane::GoalStatusIndicator;
|
||||
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn active_goal_usage_prefers_token_budget() {
|
||||
assert_eq!(
|
||||
active_goal_usage(
|
||||
Some(50_000),
|
||||
/*tokens_used*/ 12_500,
|
||||
/*time_used_seconds*/ 90
|
||||
),
|
||||
Some("12.5K / 50K".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_goal_usage_reports_time_without_budget() {
|
||||
assert_eq!(
|
||||
active_goal_usage(
|
||||
/*token_budget*/ None, /*tokens_used*/ 12_500,
|
||||
/*time_used_seconds*/ 120,
|
||||
),
|
||||
Some("2m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_goal_budget_usage_reports_budgeted_tokens() {
|
||||
assert_eq!(
|
||||
stopped_goal_budget_usage(Some(50_000), /*tokens_used*/ 63_876),
|
||||
Some("63.9K / 50K tokens".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_goal_budget_usage_omits_unbudgeted_usage() {
|
||||
assert_eq!(
|
||||
stopped_goal_budget_usage(/*token_budget*/ None, /*tokens_used*/ 12_500),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_goal_usage_reports_tokens_when_budgeted() {
|
||||
assert_eq!(
|
||||
completed_goal_usage(
|
||||
Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
/*time_used_seconds*/ 120,
|
||||
),
|
||||
"40K tokens".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_goal_usage_reports_time_without_token_budget() {
|
||||
assert_eq!(
|
||||
completed_goal_usage(
|
||||
/*token_budget*/ None, /*tokens_used*/ 40_000,
|
||||
/*time_used_seconds*/ 36_720,
|
||||
),
|
||||
"10h 12m".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_goal_status_includes_current_turn_elapsed_time() {
|
||||
let observed_at = Instant::now();
|
||||
let state = active_goal_state(observed_at, /*time_used_seconds*/ 60);
|
||||
|
||||
assert_eq!(
|
||||
state.indicator(
|
||||
observed_at + Duration::from_secs(60),
|
||||
Some(observed_at - Duration::from_secs(120)),
|
||||
),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("2m".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_goal_status_does_not_count_idle_time_before_turn_start() {
|
||||
let observed_at = Instant::now();
|
||||
let active_turn_started_at = observed_at + Duration::from_secs(120);
|
||||
let state = active_goal_state(observed_at, /*time_used_seconds*/ 60);
|
||||
|
||||
assert_eq!(
|
||||
state.indicator(
|
||||
active_turn_started_at + Duration::from_secs(60),
|
||||
Some(active_turn_started_at),
|
||||
),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("2m".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn active_goal_state(observed_at: Instant, time_used_seconds: i64) -> GoalStatusState {
|
||||
GoalStatusState::new(
|
||||
AppThreadGoal {
|
||||
thread_id: "thread".to_string(),
|
||||
objective: "do the thing".to_string(),
|
||||
status: AppThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
observed_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! slash-command recall follows the same submitted-input rule as ordinary text.
|
||||
|
||||
use super::*;
|
||||
use crate::app_event::ThreadGoalSetMode;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::slash_commands;
|
||||
|
||||
@@ -28,6 +29,8 @@ const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting...";
|
||||
const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str =
|
||||
"'/side' is unavailable while code review is running.";
|
||||
const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first.";
|
||||
const GOAL_USAGE: &str = "Usage: /goal <objective>";
|
||||
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
|
||||
|
||||
impl ChatWidget {
|
||||
/// Dispatch a bare slash command and record its staged local-history entry.
|
||||
@@ -37,6 +40,9 @@ impl ChatWidget {
|
||||
/// rule as normal text.
|
||||
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) {
|
||||
self.dispatch_command(cmd);
|
||||
if cmd == SlashCommand::Goal {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
self.bottom_pane.record_pending_slash_command_history();
|
||||
}
|
||||
|
||||
@@ -201,6 +207,20 @@ impl ChatWidget {
|
||||
SlashCommand::Plan => {
|
||||
self.apply_plan_slash_command();
|
||||
}
|
||||
SlashCommand::Goal => {
|
||||
if !self.config.features.enabled(Feature::Goals) {
|
||||
return;
|
||||
}
|
||||
if let Some(thread_id) = self.thread_id {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::OpenThreadGoalMenu { thread_id });
|
||||
} else {
|
||||
self.add_info_message(
|
||||
GOAL_USAGE.to_string(),
|
||||
Some(GOAL_USAGE_HINT.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
SlashCommand::Collab => {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
self.add_info_message(
|
||||
@@ -580,6 +600,87 @@ impl ChatWidget {
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
}
|
||||
SlashCommand::Goal if !trimmed.is_empty() => {
|
||||
if !self.config.features.enabled(Feature::Goals) {
|
||||
return;
|
||||
}
|
||||
enum GoalControlCommand {
|
||||
Clear,
|
||||
SetStatus(AppThreadGoalStatus),
|
||||
}
|
||||
let control_command = match trimmed.to_ascii_lowercase().as_str() {
|
||||
"clear" => Some(GoalControlCommand::Clear),
|
||||
"pause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Paused)),
|
||||
"unpause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Active)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(command) = control_command {
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
self.add_info_message(
|
||||
GOAL_USAGE.to_string(),
|
||||
Some(
|
||||
"The session must start before you can change a goal.".to_string(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
};
|
||||
match command {
|
||||
GoalControlCommand::Clear => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ClearThreadGoal { thread_id });
|
||||
}
|
||||
GoalControlCommand::SetStatus(status) => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SetThreadGoalStatus { thread_id, status });
|
||||
}
|
||||
}
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let objective = args.trim();
|
||||
if objective.is_empty() {
|
||||
self.add_error_message("Goal objective must not be empty.".to_string());
|
||||
self.add_info_message(
|
||||
GOAL_USAGE.to_string(),
|
||||
Some(GOAL_USAGE_HINT.to_string()),
|
||||
);
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
self.queue_user_message_with_options(
|
||||
UserMessage {
|
||||
text: format!("/goal {args}"),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
},
|
||||
QueuedInputAction::ParseSlash,
|
||||
);
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
} else {
|
||||
self.add_info_message(
|
||||
GOAL_USAGE.to_string(),
|
||||
Some("The session must start before you can set a goal.".to_string()),
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective: objective.to_string(),
|
||||
mode: ThreadGoalSetMode::ConfirmIfExists,
|
||||
});
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
}
|
||||
SlashCommand::Side if !trimmed.is_empty() => {
|
||||
let Some(parent_thread_id) = self.thread_id else {
|
||||
self.add_error_message(
|
||||
@@ -613,7 +714,7 @@ impl ChatWidget {
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
}
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
}
|
||||
@@ -675,11 +776,18 @@ impl ChatWidget {
|
||||
return QueueDrain::Stop;
|
||||
}
|
||||
|
||||
let args_elements = Self::slash_command_args_elements(rest, rest_offset, &text_elements);
|
||||
let trimmed_start = rest.trim_start();
|
||||
let leading_trimmed = rest.len().saturating_sub(trimmed_start.len());
|
||||
let trimmed_rest = trimmed_start.trim_end();
|
||||
let args_elements = Self::slash_command_args_elements(
|
||||
trimmed_rest,
|
||||
rest_offset + leading_trimmed,
|
||||
&text_elements,
|
||||
);
|
||||
self.dispatch_prepared_command_with_args(
|
||||
cmd,
|
||||
PreparedSlashCommandArgs {
|
||||
args: rest.trim().to_string(),
|
||||
args: trimmed_rest.to_string(),
|
||||
text_elements: args_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
@@ -703,6 +811,7 @@ impl ChatWidget {
|
||||
collaboration_modes_enabled: self.collaboration_modes_enabled(),
|
||||
connectors_enabled: self.connectors_enabled(),
|
||||
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
|
||||
goal_command_enabled: self.config.features.enabled(Feature::Goals),
|
||||
fast_command_enabled: self.fast_mode_enabled(),
|
||||
personality_command_enabled: self.config.features.enabled(Feature::Personality),
|
||||
realtime_conversation_enabled: self.realtime_conversation_enabled(),
|
||||
@@ -745,6 +854,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Settings
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Collab
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Agent
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/review_mode.rs
|
||||
expression: last
|
||||
---
|
||||
■ Goal budget reached - the turn was stopped.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/goal_menu.rs
|
||||
expression: rendered_goal_summary(&mut rx)
|
||||
---
|
||||
Goal
|
||||
Status: active
|
||||
Objective: Keep improving the bare goal command until it feels calm and useful.
|
||||
Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
Token budget: 80K
|
||||
|
||||
Commands: /goal pause, /goal clear
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/goal_menu.rs
|
||||
expression: rendered_goal_summary(&mut rx)
|
||||
---
|
||||
Goal
|
||||
Status: limited by budget
|
||||
Objective: Keep improving the bare goal command until it feels calm and useful.
|
||||
Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
Token budget: 80K
|
||||
|
||||
Commands: /goal clear
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/goal_menu.rs
|
||||
expression: rendered_goal_summary(&mut rx)
|
||||
---
|
||||
Goal
|
||||
Status: paused
|
||||
Objective: Keep improving the bare goal command until it feels calm and useful.
|
||||
Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
|
||||
Commands: /goal unpause, /goal clear
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/review_mode.rs
|
||||
expression: last
|
||||
---
|
||||
■ Goal budget reached - the turn was stopped.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: normalized_backend_snapshot(terminal.backend())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.4 Pursuing goal (40K / 50K) "
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: normalized_backend_snapshot(terminal.backend())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.4 Goal achieved (30m) "
|
||||
@@ -4,6 +4,7 @@
|
||||
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
|
||||
|
||||
use super::*;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: spinner + project name.
|
||||
|
||||
@@ -269,6 +269,7 @@ mod approval_requests;
|
||||
mod background_events;
|
||||
mod composer_submission;
|
||||
mod exec_flow;
|
||||
mod goal_menu;
|
||||
mod guardian;
|
||||
mod helpers;
|
||||
mod history_replay;
|
||||
|
||||
@@ -872,8 +872,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||
composer: None,
|
||||
pending_steers: VecDeque::new(),
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_active_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(80_000),
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_active", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_paused_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::Paused,
|
||||
/*token_budget*/ None,
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_budget_limited_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(80_000),
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
fn test_goal(
|
||||
thread_id: ThreadId,
|
||||
status: AppThreadGoalStatus,
|
||||
token_budget: Option<i64>,
|
||||
) -> AppThreadGoal {
|
||||
AppThreadGoal {
|
||||
thread_id: thread_id.to_string(),
|
||||
objective: "Keep improving the bare goal command until it feels calm and useful."
|
||||
.to_string(),
|
||||
status,
|
||||
token_budget,
|
||||
tokens_used: 12_500,
|
||||
time_used_seconds: 90,
|
||||
created_at: 1_776_272_400,
|
||||
updated_at: 1_776_272_460,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_goal_summary(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::app_event::AppEvent>,
|
||||
) -> String {
|
||||
drain_insert_history(rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -256,6 +256,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
last_turn_id: None,
|
||||
budget_limited_turn_ids: HashSet::new(),
|
||||
thread_name: None,
|
||||
thread_rename_block_message: None,
|
||||
active_side_conversation: false,
|
||||
@@ -267,8 +268,10 @@ pub(super) async fn make_chatwidget_manual(
|
||||
show_welcome_banner: true,
|
||||
startup_tooltip_override: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
|
||||
@@ -304,6 +307,9 @@ pub(super) async fn make_chatwidget_manual(
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
goal_status_active_turn_started_at: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_rendered_user_message_event: None,
|
||||
@@ -584,6 +590,7 @@ pub(super) fn complete_assistant_message(
|
||||
pub(super) fn pending_steer(text: &str) -> PendingSteer {
|
||||
PendingSteer {
|
||||
user_message: UserMessage::from(text),
|
||||
history_record: UserMessageHistoryRecord::UserMessageText,
|
||||
compare_key: PendingSteerCompareKey {
|
||||
message: text.to_string(),
|
||||
image_count: 0,
|
||||
|
||||
@@ -441,8 +441,11 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||
composer: None,
|
||||
pending_steers,
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
rejected_steers_queue,
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages,
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
@@ -1051,6 +1054,24 @@ async fn ctrl_c_shutdown_works_with_caps_lock() {
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -1300,6 +1321,132 @@ async fn interrupted_turn_error_message_snapshot() {
|
||||
assert_chatwidget_snapshot!("interrupted_turn_error_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::TurnStarted(
|
||||
codex_app_server_protocol::TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: codex_app_server_protocol::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
goal: codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "Run until the token budget is limited".to_string(),
|
||||
status: codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
token_budget: Some(10_000),
|
||||
tokens_used: 10_500,
|
||||
time_used_seconds: 0,
|
||||
created_at: 0,
|
||||
updated_at: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::TurnCompleted(
|
||||
codex_app_server_protocol::TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: codex_app_server_protocol::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::Interrupted,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().unwrap());
|
||||
assert_chatwidget_snapshot!("interrupted_turn_goal_budget_limited_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().unwrap());
|
||||
assert_chatwidget_snapshot!("direct_budget_limited_turn_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_limited_turn_restores_queued_input_without_submitting() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("follow-up after budget stop").into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
"follow-up after budget stop"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
// Snapshot test: interrupting specifically to submit pending steers shows an
|
||||
// informational banner instead of the generic "tell the model what to do
|
||||
// differently" error prompt.
|
||||
|
||||
@@ -30,6 +30,19 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
|
||||
chat.bottom_pane.composer_text()
|
||||
}
|
||||
|
||||
fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> String {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::AddToHistory { text }) => return text,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"),
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
panic!("expected AddToHistory op but channel closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -664,6 +677,445 @@ async fn inline_slash_command_is_available_from_local_recall_after_dispatch() {
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_emits_set_goal_event() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let command = "/goal --tokens 98.5K improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
mode,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "--tokens 98.5K improve benchmark coverage");
|
||||
assert_eq!(mode, crate::app_event::ThreadGoalSetMode::ConfirmIfExists);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(recall_latest_after_clearing(&mut chat), command);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_uses_plain_text_for_mentions() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.bottom_pane.set_composer_text_with_mention_bindings(
|
||||
"/goal use $figma for the mockup".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "use $figma for the mockup");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_drops_attached_images() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let remote_url = "https://example.com/goal.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/goal-local.png");
|
||||
let placeholder = "[Image #2]";
|
||||
let command = format!("/goal describe {placeholder}");
|
||||
let placeholder_start = command.find(placeholder).expect("placeholder in command");
|
||||
chat.set_remote_image_urls(vec![remote_url]);
|
||||
chat.bottom_pane.set_composer_text(
|
||||
command,
|
||||
vec![TextElement::new(
|
||||
(placeholder_start..placeholder_start + placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
vec![local_image],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "describe [Image #2]");
|
||||
assert!(chat.remote_image_urls().is_empty());
|
||||
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_goal_slash_command_drains_pending_submission_state() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let remote_url = "https://example.com/goal-menu.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/goal-menu-local.png");
|
||||
chat.set_remote_image_urls(vec![remote_url]);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal".to_string(), Vec::new(), vec![local_image]);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id
|
||||
);
|
||||
assert!(chat.remote_image_urls().is_empty());
|
||||
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_control_slash_commands_emit_goal_events() {
|
||||
let cases = [
|
||||
("/goal clear", None),
|
||||
("/goal pause", Some(AppThreadGoalStatus::Paused)),
|
||||
("/goal unpause", Some(AppThreadGoalStatus::Active)),
|
||||
];
|
||||
|
||||
for (command, status) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
|
||||
match status {
|
||||
Some(status) => {
|
||||
let event = rx.try_recv().expect("expected goal status event");
|
||||
let AppEvent::SetThreadGoalStatus {
|
||||
thread_id: actual_thread_id,
|
||||
status: actual_status,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalStatus, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(actual_status, status);
|
||||
}
|
||||
None => {
|
||||
let event = rx.try_recv().expect("expected clear goal event");
|
||||
let AppEvent::ClearThreadGoal {
|
||||
thread_id: actual_thread_id,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected ClearThreadGoal, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "improve benchmark coverage");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_preserves_current_draft_metadata() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let remote_url = "https://example.com/current-draft.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/current-draft-local.png");
|
||||
let placeholder = "[Image #3]";
|
||||
let draft = format!("draft with {placeholder}");
|
||||
let placeholder_start = draft.find(placeholder).expect("placeholder in draft");
|
||||
chat.set_remote_image_urls(vec![remote_url.clone()]);
|
||||
chat.bottom_pane.set_composer_text(
|
||||
draft.clone(),
|
||||
vec![TextElement::new(
|
||||
(placeholder_start..placeholder_start + placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
vec![local_image.clone()],
|
||||
);
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
assert_matches!(
|
||||
event,
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
..
|
||||
} if actual_thread_id == thread_id
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), draft);
|
||||
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_local_image_paths(),
|
||||
vec![local_image]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_queued_goal_slash_command_emits_set_goal_event() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
let input_state = chat
|
||||
.capture_thread_input_state()
|
||||
.expect("expected queued input state");
|
||||
|
||||
let (mut restored_chat, mut restored_rx, mut restored_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
restored_chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
restored_chat.restore_thread_input_state(Some(input_state));
|
||||
let thread_id = ThreadId::new();
|
||||
restored_chat.thread_id = Some(thread_id);
|
||||
restored_chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = restored_rx
|
||||
.try_recv()
|
||||
.expect("expected goal objective event");
|
||||
assert_matches!(
|
||||
event,
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
..
|
||||
} if actual_thread_id == thread_id
|
||||
);
|
||||
assert_no_submit_op(&mut restored_op_rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_history_record_preserves_raw_text_and_rebased_elements() {
|
||||
let first = UserMessage {
|
||||
text: "Ask $figma".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
|
||||
mention_bindings: vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
};
|
||||
let second = UserMessage::from("internal prompt");
|
||||
|
||||
let (_message, history_record) = merge_user_messages_with_history_record(vec![
|
||||
(first, UserMessageHistoryRecord::UserMessageText),
|
||||
(
|
||||
second,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "/goal inspect [Image #1]".to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(14..24).into(),
|
||||
Some("[Image #1]".to_string()),
|
||||
)],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
history_record,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "Ask $figma\n/goal inspect [Image #1]".to_string(),
|
||||
text_elements: vec![
|
||||
TextElement::new((4..10).into(), Some("$figma".to_string())),
|
||||
TextElement::new((25..35).into(), Some("[Image #1]".to_string())),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_history_record_remaps_override_image_placeholders() {
|
||||
let first_placeholder = "[Image #1]";
|
||||
let second_placeholder = "[Image #1]";
|
||||
let first = UserMessage {
|
||||
text: format!("first {first_placeholder}"),
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: first_placeholder.to_string(),
|
||||
path: PathBuf::from("/tmp/first.png"),
|
||||
}],
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(6..16).into(),
|
||||
Some(first_placeholder.to_string()),
|
||||
)],
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
let second = UserMessage {
|
||||
text: format!("internal {second_placeholder}"),
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: second_placeholder.to_string(),
|
||||
path: PathBuf::from("/tmp/second.png"),
|
||||
}],
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(9..19).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)],
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
let (message, history_record) = merge_user_messages_with_history_record(vec![
|
||||
(first, UserMessageHistoryRecord::UserMessageText),
|
||||
(
|
||||
second,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: format!("goal {second_placeholder}"),
|
||||
text_elements: vec![TextElement::new(
|
||||
(5..15).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(message.text, "first [Image #1]\ninternal [Image #2]");
|
||||
assert_eq!(
|
||||
message.text_elements,
|
||||
vec![
|
||||
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
|
||||
TextElement::new((26..36).into(), Some("[Image #2]".to_string())),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
message
|
||||
.local_images
|
||||
.iter()
|
||||
.map(|image| image.placeholder.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["[Image #1]", "[Image #2]"]
|
||||
);
|
||||
assert_eq!(
|
||||
history_record,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "first [Image #1]\ngoal [Image #2]".to_string(),
|
||||
text_elements: vec![
|
||||
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
|
||||
TextElement::new((22..32).into(), Some("[Image #2]".to_string())),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_merged_message_history_encodes_mentions_once() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
let text = "use $figma now";
|
||||
chat.bottom_pane.set_composer_text_with_mention_bindings(
|
||||
text.to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => {
|
||||
let [
|
||||
UserInput::Text {
|
||||
text: submitted, ..
|
||||
},
|
||||
] = items.as_slice()
|
||||
else {
|
||||
panic!("expected text item, got {items:?}");
|
||||
};
|
||||
assert_eq!(submitted, text);
|
||||
}
|
||||
other => panic!("expected user turn, got {other:?}"),
|
||||
}
|
||||
let encoded = "use [$figma](app://figma) now";
|
||||
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
next_interrupt_op(&mut op_rx);
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => {
|
||||
let [
|
||||
UserInput::Text {
|
||||
text: submitted, ..
|
||||
},
|
||||
] = items.as_slice()
|
||||
else {
|
||||
panic!("expected resubmitted text item, got {items:?}");
|
||||
};
|
||||
assert_eq!(submitted, text);
|
||||
}
|
||||
other => panic!("expected resubmitted user turn, got {other:?}"),
|
||||
}
|
||||
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rename_prefills_existing_thread_name() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -1034,6 +1486,91 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "finish the benchmark".to_string(),
|
||||
status: codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.queue_user_message("Continue".into());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
queue_composer_text_with_tab(&mut chat, "/model");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))),
|
||||
});
|
||||
|
||||
assert_matches!(
|
||||
chat.pending_notification,
|
||||
Some(Notification::AgentTurnComplete { ref response }) if response == "Done"
|
||||
);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Select Model"));
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::goal_status_indicator_line;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Receiving a TokenCount event without usage clears the context indicator.
|
||||
@@ -1628,6 +1629,279 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_goal_active_token_budget_footer_snapshot() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
|
||||
chat.refresh_status_line();
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw goal status footer");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_line_goal_active_token_budget_footer",
|
||||
normalized_backend_snapshot(terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_goal_complete_elapsed_footer_snapshot() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
|
||||
chat.refresh_status_line();
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Complete,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw goal status footer");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_line_goal_complete_elapsed_footer",
|
||||
normalized_backend_snapshot(terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_configured_clears_goal_status_footer() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
chat.current_goal_status_indicator,
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("40K / 50K".to_string())
|
||||
})
|
||||
);
|
||||
chat.budget_limited_turn_ids.insert("turn-1".to_string());
|
||||
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-2".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-5.4".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.current_goal_status_indicator, None);
|
||||
assert!(chat.budget_limited_turn_ids.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_update_for_other_thread_is_ignored() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let other_thread_id = ThreadId::new().to_string();
|
||||
let mut goal = test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 50_000,
|
||||
);
|
||||
goal.thread_id = other_thread_id.clone();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: other_thread_id,
|
||||
turn_id: Some("turn-other".to_string()),
|
||||
goal,
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(chat.current_goal_status_indicator, None);
|
||||
assert!(chat.current_goal_status.is_none());
|
||||
assert!(chat.budget_limited_turn_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_status_indicator_formats_statuses_and_budgets() {
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("40K / 50K".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 0,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("30m".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 51_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::BudgetLimited {
|
||||
usage: Some("51K / 50K tokens".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 0,
|
||||
)),
|
||||
Some(GoalStatusIndicator::BudgetLimited { usage: None })
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Complete,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Complete {
|
||||
usage: Some("40K tokens".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_status_indicator_line_formats_goal_text() {
|
||||
let cases = [
|
||||
(
|
||||
GoalStatusIndicator::Active {
|
||||
usage: Some("4K / 5K".to_string()),
|
||||
},
|
||||
"Pursuing goal (4K / 5K)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::BudgetLimited {
|
||||
usage: Some("4K / 5K tokens".to_string()),
|
||||
},
|
||||
"Goal unmet (4K / 5K tokens)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Paused,
|
||||
"Goal paused (/goal to unpause)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::BudgetLimited { usage: None },
|
||||
"Goal abandoned",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Complete {
|
||||
usage: Some("10h 12m".to_string()),
|
||||
},
|
||||
"Goal achieved (10h 12m)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Complete { usage: None },
|
||||
"Goal achieved",
|
||||
),
|
||||
];
|
||||
|
||||
for (indicator, expected) in cases {
|
||||
let line =
|
||||
goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render");
|
||||
let actual = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_thread_goal(
|
||||
status: codex_app_server_protocol::ThreadGoalStatus,
|
||||
token_budget: Option<i64>,
|
||||
tokens_used: i64,
|
||||
) -> codex_app_server_protocol::ThreadGoal {
|
||||
codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "Keep improving the benchmark".to_string(),
|
||||
status,
|
||||
token_budget,
|
||||
tokens_used,
|
||||
time_used_seconds: 30 * 60,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::status::format_tokens_compact;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
|
||||
pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String {
|
||||
let seconds = seconds.max(0) as u64;
|
||||
if seconds < 60 {
|
||||
return format!("{seconds}s");
|
||||
}
|
||||
|
||||
let minutes = seconds / 60;
|
||||
if minutes < 60 {
|
||||
return format!("{minutes}m");
|
||||
}
|
||||
|
||||
let hours = minutes / 60;
|
||||
let remaining_minutes = minutes % 60;
|
||||
if remaining_minutes == 0 {
|
||||
format!("{hours}h")
|
||||
} else {
|
||||
format!("{hours}h {remaining_minutes}m")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str {
|
||||
match status {
|
||||
ThreadGoalStatus::Active => "active",
|
||||
ThreadGoalStatus::Paused => "paused",
|
||||
ThreadGoalStatus::BudgetLimited => "limited by budget",
|
||||
ThreadGoalStatus::Complete => "complete",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn goal_usage_summary(goal: &ThreadGoal) -> String {
|
||||
let mut parts = vec![format!("Objective: {}", goal.objective)];
|
||||
if goal.time_used_seconds > 0 {
|
||||
parts.push(format!(
|
||||
"Time: {}.",
|
||||
format_goal_elapsed_seconds(goal.time_used_seconds)
|
||||
));
|
||||
}
|
||||
if let Some(token_budget) = goal.token_budget {
|
||||
parts.push(format!(
|
||||
"Tokens: {}/{}.",
|
||||
format_tokens_compact(goal.tokens_used),
|
||||
format_tokens_compact(token_budget)
|
||||
));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn format_goal_elapsed_seconds_is_compact() {
|
||||
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 0), "0s");
|
||||
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 59), "59s");
|
||||
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 60), "1m");
|
||||
assert_eq!(format_goal_elapsed_seconds(30 * 60), "30m");
|
||||
assert_eq!(format_goal_elapsed_seconds(90 * 60), "1h 30m");
|
||||
assert_eq!(format_goal_elapsed_seconds(2 * 60 * 60), "2h");
|
||||
}
|
||||
|
||||
fn test_thread_goal(token_budget: Option<i64>, tokens_used: i64) -> ThreadGoal {
|
||||
ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "Complete the task described in ../gameboy-long-running-prompt5.txt"
|
||||
.to_string(),
|
||||
status: ThreadGoalStatus::BudgetLimited,
|
||||
token_budget,
|
||||
tokens_used,
|
||||
time_used_seconds: 120,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_usage_summary_formats_time_and_budgeted_tokens() {
|
||||
assert_eq!(
|
||||
goal_usage_summary(&test_thread_goal(
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 63_876,
|
||||
)),
|
||||
"Objective: Complete the task described in ../gameboy-long-running-prompt5.txt Time: 2m. Tokens: 63.9K/50K."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ mod external_editor;
|
||||
mod file_search;
|
||||
mod frames;
|
||||
mod get_git_diff;
|
||||
mod goal_display;
|
||||
mod history_cell;
|
||||
pub(crate) mod insert_history;
|
||||
pub use insert_history::insert_history_lines;
|
||||
|
||||
@@ -31,6 +31,7 @@ pub enum SlashCommand {
|
||||
Init,
|
||||
Compact,
|
||||
Plan,
|
||||
Goal,
|
||||
Collab,
|
||||
Agent,
|
||||
Side,
|
||||
@@ -104,6 +105,7 @@ impl SlashCommand {
|
||||
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
|
||||
SlashCommand::Settings => "configure realtime microphone/speaker",
|
||||
SlashCommand::Plan => "switch to Plan mode",
|
||||
SlashCommand::Goal => "set or view the goal for a long-running task",
|
||||
SlashCommand::Collab => "change collaboration mode (experimental)",
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
|
||||
SlashCommand::Side => "start a side conversation in an ephemeral fork",
|
||||
@@ -137,6 +139,7 @@ impl SlashCommand {
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Side
|
||||
@@ -186,6 +189,7 @@ impl SlashCommand {
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Stop
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Apps
|
||||
| SlashCommand::Plugins
|
||||
@@ -239,4 +243,9 @@ mod tests {
|
||||
fn clean_alias_parses_to_stop_command() {
|
||||
assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_command_is_available_during_task() {
|
||||
assert!(SlashCommand::Goal.available_during_task());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user