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
+113 -3
View File
@@ -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