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:
Eric Traut
2026-04-24 21:16:45 -07:00
committed by GitHub
parent 4167628622
commit f1c963d77e
32 changed files with 2709 additions and 177 deletions
+53 -24
View File
@@ -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,
+40
View File
@@ -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()])
+13
View File
@@ -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();