feat(tui): add Goal mode and post-turn receipts

Goal mode (v0.8.43 truth-surface):
- New AppMode::Goal variant orthogonal to Plan/Agent/YOLO
- /goal command to set/complete objectives
- Goal status displayed in Work sidebar with elapsed time
- Alt+G keybinding to toggle Goal mode
- Mode parsed from /mode goal|4 command

Receipts (v0.8.43 truth-surface):
- ToolEvidence struct collects per-turn tool summaries
- Post-turn receipt generated on TurnComplete
- Receipt rendered as dimmed line at transcript tail
- Receipt/evidence cleared on new turn dispatch

Also:
- Fix AppMode::Goal exhaustive pattern coverage across 5 files
- Update doctor error message (deepseek → codewhale)
- Fix clippy::useless_format warning
This commit is contained in:
Hunter Bown
2026-05-24 04:07:30 -05:00
parent 243cfe5227
commit cc7c4a2ea9
16 changed files with 218 additions and 37 deletions
+3 -1
View File
@@ -659,7 +659,7 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
};
match parse_mode_arg(arg) {
Some(mode) => CommandResult::message(switch_mode(app, mode)),
None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"),
None => CommandResult::error("Usage: /mode [agent|plan|yolo|goal|1|2|3|4]"),
}
}
@@ -676,6 +676,7 @@ fn parse_mode_arg(arg: &str) -> Option<AppMode> {
"agent" | "1" => Some(AppMode::Agent),
"plan" | "2" => Some(AppMode::Plan),
"yolo" | "3" => Some(AppMode::Yolo),
"goal" | "4" => Some(AppMode::Goal),
_ => None,
}
}
@@ -685,6 +686,7 @@ fn mode_display_name(mode: AppMode) -> &'static str {
AppMode::Agent => "Agent",
AppMode::Plan => "Plan",
AppMode::Yolo => "YOLO",
AppMode::Goal => "Goal",
}
}
+3
View File
@@ -354,6 +354,9 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip));
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip));
}
AppMode::Goal => {
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeGoalModeTip));
}
}
CommandResult::message(stats)
+20 -2
View File
@@ -7,18 +7,29 @@ use super::CommandResult;
/// Set or show the current goal
pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg {
Some("clear") | Some("reset") | Some("done") => {
Some("clear") | Some("reset") => {
app.goal.goal_objective = None;
app.goal.goal_token_budget = None;
app.goal.goal_started_at = None;
app.goal.goal_completed = false;
CommandResult::message("Goal cleared.")
}
Some("done") | Some("complete") => {
app.goal.goal_completed = true;
let elapsed = app
.goal
.goal_started_at
.map(|t| crate::tui::notifications::humanize_duration(t.elapsed()))
.unwrap_or_else(|| "unknown".to_string());
CommandResult::message(format!("Goal marked complete! Elapsed: {elapsed}"))
}
Some(text) if !text.is_empty() => {
// Parse optional budget: "/goal Implement login | budget: 50000"
let (objective, budget) = parse_goal_budget(text);
app.goal.goal_objective = Some(objective.clone());
app.goal.goal_token_budget = budget;
app.goal.goal_started_at = Some(std::time::Instant::now());
app.goal.goal_completed = false;
let budget_str = budget
.map(|b| format!(" (budget: {b} tokens)"))
.unwrap_or_default();
@@ -50,7 +61,14 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult {
format!(" | tokens: {used}/{b} ({pct:.0}%)")
})
.unwrap_or_default();
CommandResult::message(format!("Goal: \"{obj}\" — elapsed: {elapsed}{budget_str}"))
let status = if app.goal.goal_completed {
" [COMPLETED]"
} else {
""
};
CommandResult::message(format!(
"Goal{status}: \"{obj}\" — elapsed: {elapsed}{budget_str}"
))
} else {
CommandResult::message(
"No goal set. Use /goal <objective> [budget: N] to set one.\n\
+3
View File
@@ -215,6 +215,7 @@ pub enum DefaultModeValue {
Agent,
Plan,
Yolo,
Goal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -806,6 +807,7 @@ impl DefaultModeValue {
Self::Agent => "agent",
Self::Plan => "plan",
Self::Yolo => "yolo",
Self::Goal => "goal",
}
}
}
@@ -917,6 +919,7 @@ impl From<&str> for DefaultModeValue {
AppMode::Agent => Self::Agent,
AppMode::Plan => Self::Plan,
AppMode::Yolo => Self::Yolo,
AppMode::Goal => Self::Goal,
}
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::sandbox::SandboxPolicy;
pub(crate) fn sandbox_policy_for_mode(mode: AppMode, workspace: &Path) -> SandboxPolicy {
match mode {
AppMode::Plan => SandboxPolicy::ReadOnly,
AppMode::Agent => SandboxPolicy::WorkspaceWrite {
AppMode::Agent | AppMode::Goal => SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.to_path_buf()],
network_access: true,
exclude_tmpdir: false,
+13
View File
@@ -422,6 +422,7 @@ pub enum MessageId {
HomeYoloModeCaution,
HomePlanModeTip,
HomePlanModeChecklistTip,
HomeGoalModeTip,
// Onboarding screens — language picker.
OnboardLanguageTitle,
OnboardLanguageBlurb,
@@ -658,6 +659,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::HomeYoloModeCaution,
MessageId::HomePlanModeTip,
MessageId::HomePlanModeChecklistTip,
MessageId::HomeGoalModeTip,
MessageId::OnboardLanguageTitle,
MessageId::OnboardLanguageBlurb,
MessageId::OnboardLanguageFooter,
@@ -1157,6 +1159,9 @@ fn english(id: MessageId) -> &'static str {
MessageId::HomeYoloModeCaution => " Be careful with destructive operations!",
MessageId::HomePlanModeTip => "Plan mode - Design before implementing",
MessageId::HomePlanModeChecklistTip => " Use /mode plan to create structured checklists",
MessageId::HomeGoalModeTip => {
"Goal mode - Set /goal <objective> to track a persistent objective"
}
// Onboarding — language picker.
MessageId::OnboardLanguageTitle => "Choose your language",
MessageId::OnboardLanguageBlurb => {
@@ -1544,6 +1549,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::HomePlanModeChecklistTip => {
" /mode plan を使って構造化されたチェックリストを作成"
}
MessageId::HomeGoalModeTip => "Goal モード - /goal <目標> で持続的な目標を追跡",
// Onboarding — language picker.
MessageId::OnboardLanguageTitle => "言語を選択",
MessageId::OnboardLanguageBlurb => {
@@ -1859,6 +1865,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::HomeYoloModeCaution => " 请小心破坏性操作!",
MessageId::HomePlanModeTip => "Plan 模式 - 先设计再实现",
MessageId::HomePlanModeChecklistTip => " 使用 /mode plan 创建结构化检查清单",
MessageId::HomeGoalModeTip => "Goal 模式 - 设置 /goal <目标> 以跟踪持久目标",
// Onboarding — language picker.
MessageId::OnboardLanguageTitle => "选择语言",
MessageId::OnboardLanguageBlurb => {
@@ -2230,6 +2237,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::HomePlanModeChecklistTip => {
" Use /mode plan para criar checklists estruturados"
}
MessageId::HomeGoalModeTip => {
"Modo Goal - Use /goal <objetivo> para rastrear um objetivo persistente"
}
// Onboarding — language picker.
MessageId::OnboardLanguageTitle => "Escolha o idioma",
MessageId::OnboardLanguageBlurb => {
@@ -2623,6 +2633,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::HomePlanModeChecklistTip => {
" Usa /mode plan para crear checklists estructurados"
}
MessageId::HomeGoalModeTip => {
"Modo Goal - Usa /goal <objetivo> para seguir un objetivo persistente"
}
MessageId::OnboardLanguageTitle => "Elige el idioma",
MessageId::OnboardLanguageBlurb => {
"Elige el idioma de la interfaz. Puedes cambiarlo en cualquier momento con `/settings set locale <etiqueta>`."
+9
View File
@@ -242,6 +242,7 @@ pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
pub const MODE_GOAL: Color = Color::Rgb(100, 220, 160); // Mint green
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
#[allow(dead_code)]
@@ -332,6 +333,7 @@ pub struct UiTheme {
pub mode_agent: Color,
pub mode_yolo: Color,
pub mode_plan: Color,
pub mode_goal: Color,
/// Statusline status colors
pub status_ready: Color,
pub status_working: Color,
@@ -358,6 +360,7 @@ pub const UI_THEME: UiTheme = UiTheme {
mode_agent: MODE_AGENT,
mode_yolo: MODE_YOLO,
mode_plan: MODE_PLAN,
mode_goal: MODE_GOAL,
status_ready: TEXT_MUTED,
status_working: DEEPSEEK_SKY,
status_warning: STATUS_WARNING,
@@ -382,6 +385,7 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
mode_agent: DEEPSEEK_BLUE,
mode_yolo: DEEPSEEK_RED,
mode_plan: Color::Rgb(180, 83, 9),
mode_goal: Color::Rgb(80, 180, 130), // mint green
status_ready: LIGHT_TEXT_MUTED,
status_working: DEEPSEEK_BLUE,
status_warning: Color::Rgb(180, 83, 9),
@@ -406,6 +410,7 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
mode_agent: GRAYSCALE_TEXT_SOFT,
mode_yolo: GRAYSCALE_TEXT_BODY,
mode_plan: GRAYSCALE_TEXT_MUTED,
mode_goal: GRAYSCALE_TEXT_SOFT,
status_ready: GRAYSCALE_TEXT_MUTED,
status_working: GRAYSCALE_TEXT_SOFT,
status_warning: GRAYSCALE_TEXT_BODY,
@@ -430,6 +435,7 @@ pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), // green
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
@@ -454,6 +460,7 @@ pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), // green
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
@@ -478,6 +485,7 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme {
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), // green
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
@@ -502,6 +510,7 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), // green
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
+3 -3
View File
@@ -409,7 +409,7 @@ impl Personality {
fn mode_prompt(mode: AppMode) -> &'static str {
match mode {
AppMode::Agent => AGENT_MODE,
AppMode::Agent | AppMode::Goal => AGENT_MODE,
AppMode::Yolo => YOLO_MODE,
AppMode::Plan => PLAN_MODE,
}
@@ -417,7 +417,7 @@ fn mode_prompt(mode: AppMode) -> &'static str {
fn default_approval_mode_for_mode(mode: AppMode) -> ApprovalMode {
match mode {
AppMode::Agent => ApprovalMode::Suggest,
AppMode::Agent | AppMode::Goal => ApprovalMode::Suggest,
AppMode::Yolo => ApprovalMode::Auto,
AppMode::Plan => ApprovalMode::Never,
}
@@ -427,7 +427,7 @@ fn approval_prompt_for_mode(mode: AppMode, approval_mode: ApprovalMode) -> &'sta
match mode {
AppMode::Yolo => AUTO_APPROVAL,
AppMode::Plan => NEVER_APPROVAL,
AppMode::Agent => match approval_mode {
AppMode::Agent | AppMode::Goal => match approval_mode {
ApprovalMode::Auto => AUTO_APPROVAL,
ApprovalMode::Suggest => SUGGEST_APPROVAL,
ApprovalMode::Never => NEVER_APPROVAL,
+41 -11
View File
@@ -127,6 +127,7 @@ pub enum AppMode {
Agent,
Yolo,
Plan,
Goal,
}
/// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263).
@@ -737,6 +738,7 @@ impl AppMode {
match value.trim().to_ascii_lowercase().as_str() {
"plan" => Self::Plan,
"yolo" => Self::Yolo,
"goal" => Self::Goal,
_ => Self::Agent,
}
}
@@ -747,6 +749,7 @@ impl AppMode {
Self::Agent => "agent",
Self::Yolo => "yolo",
Self::Plan => "plan",
Self::Goal => "goal",
}
}
@@ -756,6 +759,7 @@ impl AppMode {
AppMode::Agent => "AGENT",
AppMode::Yolo => "YOLO",
AppMode::Plan => "PLAN",
AppMode::Goal => "GOAL",
}
}
@@ -766,6 +770,7 @@ impl AppMode {
AppMode::Agent => "Agent mode - autonomous task execution with tools",
AppMode::Yolo => "YOLO mode - full tool access without approvals",
AppMode::Plan => "Plan mode - design before implementing",
AppMode::Goal => "Goal mode - track a persistent objective across turns",
}
}
}
@@ -973,6 +978,7 @@ pub struct GoalState {
pub goal_objective: Option<String>,
pub goal_token_budget: Option<u32>,
pub goal_started_at: Option<Instant>,
pub goal_completed: bool,
}
/// Session cost and token telemetry state.
@@ -1019,6 +1025,13 @@ impl Default for SessionState {
}
}
/// Evidence collected during a turn for the post-turn receipt.
#[derive(Debug, Clone)]
pub struct ToolEvidence {
pub tool_name: String,
pub summary: String,
}
/// Global UI state for the TUI.
#[allow(clippy::struct_excessive_bools)]
pub struct App {
@@ -1416,6 +1429,12 @@ pub struct App {
/// Derived title for the current session shown in the composer border.
/// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded.
pub session_title: Option<String>,
/// Post-turn receipt line rendered at the bottom of the transcript.
/// Set when a turn completes; cleared when a new turn starts.
pub receipt_text: Option<String>,
/// Tool evidence collected during the current turn for the receipt.
pub tool_evidence: Vec<ToolEvidence>,
}
/// Message queued while the engine is busy.
@@ -1926,6 +1945,8 @@ impl App {
.and_then(|tui| tui.composer_arrows_scroll)
.unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)),
session_title: None,
receipt_text: None,
tool_evidence: Vec::new(),
}
}
@@ -2039,12 +2060,13 @@ impl App {
true
}
/// Cycle through modes: Plan → Agent → YOLO → Plan.
/// Cycle through modes: Plan → Agent → YOLO → Goal → Plan.
pub fn cycle_mode(&mut self) {
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Plan,
AppMode::Yolo => AppMode::Goal,
AppMode::Goal => AppMode::Plan,
};
let _ = self.set_mode(next);
}
@@ -2055,7 +2077,8 @@ impl App {
let next = match self.mode {
AppMode::Agent => AppMode::Plan,
AppMode::Yolo => AppMode::Agent,
AppMode::Plan => AppMode::Yolo,
AppMode::Plan => AppMode::Goal,
AppMode::Goal => AppMode::Yolo,
};
let _ = self.set_mode(next);
}
@@ -5362,11 +5385,15 @@ mod tests {
app.mode = AppMode::Plan;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Yolo);
assert_eq!(app.mode, AppMode::Goal);
app.mode = AppMode::Agent;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Plan);
app.mode = AppMode::Goal;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Yolo);
}
#[test]
@@ -5375,17 +5402,20 @@ mod tests {
let first_mode = match app.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Plan,
AppMode::Yolo => AppMode::Goal,
AppMode::Goal => AppMode::Plan,
};
let second_mode = match first_mode {
AppMode::Plan => AppMode::Yolo,
AppMode::Agent => AppMode::Plan,
AppMode::Yolo => AppMode::Agent,
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Goal,
AppMode::Yolo => AppMode::Plan,
AppMode::Goal => AppMode::Yolo,
};
let third_mode = match second_mode {
AppMode::Plan => AppMode::Yolo,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Plan,
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Goal,
AppMode::Yolo => AppMode::Goal,
AppMode::Goal => AppMode::Plan,
};
app.set_mode(first_mode);
+7 -9
View File
@@ -120,11 +120,7 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> {
if running_agent_count(app) > 0 {
return Some("sub-agents working");
}
if app
.task_panel
.iter()
.any(|task| task.status == "running")
{
if app.task_panel.iter().any(|task| task.status == "running") {
return Some("background jobs running");
}
let active = app.active_cell.as_ref()?;
@@ -133,9 +129,10 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> {
crate::tui::history::ToolCell::Exec(exec) => {
exec.status == crate::tui::history::ToolStatus::Running
}
crate::tui::history::ToolCell::Exploring(explore) => {
explore.entries.iter().any(|e| e.status == crate::tui::history::ToolStatus::Running)
}
crate::tui::history::ToolCell::Exploring(explore) => explore
.entries
.iter()
.any(|e| e.status == crate::tui::history::ToolStatus::Running),
_ => false,
},
_ => false,
@@ -143,7 +140,7 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> {
return Some("tools executing");
}
if app.runtime_turn_status.as_deref() == Some("in_progress") {
return Some("waiting no recent activity");
return Some("waiting - no recent activity");
}
None
}
@@ -786,6 +783,7 @@ pub(crate) fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Col
crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent,
crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo,
crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan,
crate::tui::app::AppMode::Goal => app.ui_theme.mode_goal,
};
(label, color)
}
+37 -7
View File
@@ -5,7 +5,7 @@
//! reads from `App` snapshots; mutation lives in the main app loop.
use std::fmt::Write;
use std::time::Duration;
use std::time::{Duration, Instant};
use ratatui::{
Frame,
@@ -167,6 +167,8 @@ struct SidebarWorkStrategyStep {
struct SidebarWorkSummary {
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
goal_completed: bool,
goal_started_at: Option<Instant>,
tokens_used: u32,
cycle_count: u32,
checklist_completion_pct: u8,
@@ -226,6 +228,8 @@ fn sidebar_work_summary(app: &App) -> SidebarWorkSummary {
let mut summary = SidebarWorkSummary {
goal_objective: app.goal.goal_objective.clone(),
goal_token_budget: app.goal.goal_token_budget,
goal_completed: app.goal.goal_completed,
goal_started_at: app.goal.goal_started_at,
tokens_used: app.session.total_conversation_tokens,
cycle_count: app.cycle_count,
..SidebarWorkSummary::default()
@@ -328,16 +332,42 @@ fn push_work_goal_lines(
return;
}
lines.push(Line::from(Span::styled(
format!(
"◆ {}",
truncate_line_to_width(objective, content_width.saturating_sub(2).max(1))
),
let icon = if summary.goal_completed { "" } else { "" };
let status_style = if summary.goal_completed {
Style::default()
.fg(palette::STATUS_SUCCESS)
.add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default()
.fg(palette::STATUS_WARNING)
.add_modifier(ratatui::style::Modifier::BOLD),
.add_modifier(ratatui::style::Modifier::BOLD)
};
lines.push(Line::from(Span::styled(
format!(
"{} {}",
icon,
truncate_line_to_width(objective, content_width.saturating_sub(2).max(1))
),
status_style,
)));
// Elapsed time
if let Some(started) = summary.goal_started_at
&& lines.len() < max_rows
{
let elapsed = crate::tui::notifications::humanize_duration(started.elapsed());
let elapsed_str = if summary.goal_completed {
format!("completed in {elapsed}")
} else {
format!("elapsed: {elapsed}")
};
lines.push(Line::from(Span::styled(
truncate_line_to_width(&elapsed_str, content_width),
Style::default().fg(palette::TEXT_MUTED),
)));
}
if let Some(budget) = summary.goal_token_budget
&& lines.len() < max_rows
{
+17 -1
View File
@@ -7,7 +7,7 @@ use crate::hooks::HookEvent;
use crate::tools::ReviewOutput;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{App, ToolDetailRecord};
use crate::tui::app::{App, ToolDetailRecord, ToolEvidence};
use crate::tui::history::{
DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell,
McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus,
@@ -693,6 +693,22 @@ pub(super) fn handle_tool_call_complete(
.with_tool_result(&result_text, success, None);
let _ = app.execute_hooks(HookEvent::ToolCallAfter, &context);
}
// Collect evidence for the post-turn receipt.
let evidence_summary = match result.as_ref() {
Ok(tool_result) => {
if tool_result.success {
summarize_tool_output(&tool_result.content)
} else {
format!("failed: {}", summarize_tool_output(&tool_result.content))
}
}
Err(err) => format!("error: {err}"),
};
app.tool_evidence.push(ToolEvidence {
tool_name: name.to_string(),
summary: evidence_summary,
});
}
fn refresh_active_tool_completion_timestamp(app: &mut App, cell_index: usize) {
+32 -1
View File
@@ -1481,6 +1481,24 @@ async fn run_event_loop(
);
}
// Generate post-turn receipt for completed turns.
if status == crate::core::events::TurnOutcomeStatus::Completed {
let tool_count = app.tool_evidence.len();
let mut receipt = "✓ turn completed".to_string();
if tool_count > 0 {
let _ = write!(receipt, " · {tool_count} tool(s) used");
for evidence in &app.tool_evidence {
let summary = if evidence.summary.len() > 60 {
format!("{}", &evidence.summary[..57])
} else {
evidence.summary.clone()
};
let _ = write!(receipt, " · {}: {summary}", evidence.tool_name);
}
}
app.receipt_text = Some(receipt);
}
// Auto-save completed turn and clear crash checkpoint.
// Offloaded to the persistence actor so the UI
// stays responsive.
@@ -3509,7 +3527,9 @@ async fn run_event_loop(
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let new_mode = match app.mode {
AppMode::Plan => AppMode::Agent,
_ => AppMode::Plan,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Goal,
AppMode::Goal => AppMode::Plan,
};
app.set_mode(new_mode);
}
@@ -3540,6 +3560,14 @@ async fn run_event_loop(
app.set_mode(AppMode::Plan);
continue;
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Goal);
continue;
}
KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Goal);
continue;
}
KeyCode::Char('v') | KeyCode::Char('V')
if key.modifiers.contains(KeyModifiers::ALT) =>
{
@@ -4009,6 +4037,9 @@ async fn dispatch_user_message(
app.runtime_turn_status = None;
app.last_send_at = Some(dispatch_started_at);
app.last_submitted_prompt = Some(message.display.clone());
// Clear the previous turn's receipt and evidence.
app.receipt_text = None;
app.tool_evidence.clear();
let cwd = std::env::current_dir().ok();
let references = crate::tui::file_mention::context_references_from_input(
+2
View File
@@ -292,11 +292,13 @@ fn mode_style(app: &App) -> (&'static str, Color) {
AppMode::Agent => "agent",
AppMode::Yolo => "yolo",
AppMode::Plan => "plan",
AppMode::Goal => "goal",
};
let color = match app.mode {
AppMode::Agent => app.ui_theme.mode_agent,
AppMode::Yolo => app.ui_theme.mode_yolo,
AppMode::Plan => app.ui_theme.mode_plan,
AppMode::Goal => app.ui_theme.mode_goal,
};
(label, color)
}
+2
View File
@@ -181,6 +181,7 @@ impl<'a> HeaderWidget<'a> {
AppMode::Agent => palette::MODE_AGENT,
AppMode::Yolo => palette::MODE_YOLO,
AppMode::Plan => palette::MODE_PLAN,
AppMode::Goal => palette::MODE_GOAL,
}
}
@@ -189,6 +190,7 @@ impl<'a> HeaderWidget<'a> {
AppMode::Agent => "Agent",
AppMode::Yolo => "Yolo",
AppMode::Plan => "Plan",
AppMode::Goal => "Goal",
}
}
+25 -1
View File
@@ -284,7 +284,30 @@ impl ChatWidget {
apply_selection(&mut lines, top, app);
if app.viewport.transcript_scroll.is_at_tail() {
// Post-turn receipt line: rendered at the bottom of the transcript
// when a turn has just completed and the viewport is at the tail.
if let Some(ref receipt) = app.receipt_text {
if app.viewport.transcript_scroll.is_at_tail() {
// Make room: if we're already at full height, drop the last
// cache line so the receipt doesn't push content off-screen.
if lines.len() >= visible_lines {
lines.pop();
}
// Pad to fill remaining space above the receipt.
let pad_target = visible_lines.saturating_sub(1);
let pad = pad_target.saturating_sub(lines.len());
for _ in 0..pad {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
format!(" {receipt}"),
Style::default()
.fg(palette::TEXT_MUTED)
.add_modifier(Modifier::DIM),
)));
app.viewport.last_transcript_padding_top = 0;
}
} else if app.viewport.transcript_scroll.is_at_tail() {
app.viewport.last_transcript_padding_top = visible_lines.saturating_sub(lines.len());
pad_lines_to_bottom(&mut lines, visible_lines);
}
@@ -504,6 +527,7 @@ impl<'a> ComposerWidget<'a> {
AppMode::Agent => palette::MODE_AGENT,
AppMode::Yolo => palette::MODE_YOLO,
AppMode::Plan => palette::MODE_PLAN,
AppMode::Goal => palette::MODE_GOAL,
}
}