diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..445976a5 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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 { "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", } } diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 9e8fd775..dd4963ab 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -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) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 7ccaff28..47a4d62e 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -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 [budget: N] to set one.\n\ diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 7e400496..59ea3d7f 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -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, } } } diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2354d6a8..7d11de23 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -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, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 1ef618a3..961ee973 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -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 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 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 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 `." diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c792d97e..c9980200 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -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 diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 8b5c1c64..f4bbe9d7 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -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, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 74572590..ef2fac39 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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, pub goal_token_budget: Option, pub goal_started_at: Option, + 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, + + /// 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, + /// Tool evidence collected during the current turn for the receipt. + pub tool_evidence: Vec, } /// 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); diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index fbe53e81..3b8ea94f 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -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) } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 87bc243f..4e841131 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -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, goal_token_budget: Option, + goal_completed: bool, + goal_started_at: Option, 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 { diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index e688d624..5f47f6a3 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -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) { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c9fd4c52..69c78c6e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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( diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 01ac69f8..74a9662f 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -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) } diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index 3c680412..f70e6871 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -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", } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 7f8060d7..4d65b867 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -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, } }