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:
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user