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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user