| 1 | //! /goal command — set a session objective with token budget and progress tracking. |
| 2 | |
| 3 | use crate::tui::app::App; |
| 4 | |
| 5 | use super::CommandResult; |
| 6 | |
| 7 | /// Set or show the current goal |
| 8 | pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 9 | match arg { |
| 10 | Some("clear") | Some("reset") | Some("done") => { |
| 11 | app.goal.goal_objective = None; |
| 12 | app.goal.goal_token_budget = None; |
| 13 | app.goal.goal_started_at = None; |
| 14 | CommandResult::message("Goal cleared.") |
| 15 | } |
| 16 | Some(text) if !text.is_empty() => { |
| 17 | // Parse optional budget: "/goal Implement login | budget: 50000" |
| 18 | let (objective, budget) = parse_goal_budget(text); |
| 19 | app.goal.goal_objective = Some(objective.clone()); |
| 20 | app.goal.goal_token_budget = budget; |
| 21 | app.goal.goal_started_at = Some(std::time::Instant::now()); |
| 22 | let budget_str = budget |
| 23 | .map(|b| format!(" (budget: {b} tokens)")) |
| 24 | .unwrap_or_default(); |
| 25 | CommandResult::message(format!( |
| 26 | "Goal set: \"{}\"{} — tracking progress.", |
| 27 | objective, budget_str |
| 28 | )) |
| 29 | } |
| 30 | _ => { |
| 31 | // Show current goal |
| 32 | if let Some(ref obj) = app.goal.goal_objective { |
| 33 | // #447: render long elapsed times as `2d 3h` rather |
| 34 | // than Rust's default Debug `Duration` (which produces |
| 35 | // `188415.234s` or similar for multi-day goals). |
| 36 | let elapsed = app |
| 37 | .goal |
| 38 | .goal_started_at |
| 39 | .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) |
| 40 | .unwrap_or_else(|| "unknown".to_string()); |
| 41 | let budget_str = app |
| 42 | .goal |
| 43 | .goal_token_budget |
| 44 | .map(|b| { |
| 45 | let used = app.session.total_conversation_tokens; |
| 46 | let pct = if b > 0 { |
| 47 | (used as f64 / b as f64 * 100.0).min(100.0) |
| 48 | } else { |
| 49 | 0.0 |
| 50 | }; |
| 51 | format!(" | tokens: {used}/{b} ({pct:.0}%)") |
| 52 | }) |
| 53 | .unwrap_or_default(); |
| 54 | CommandResult::message(format!("Goal: \"{obj}\" — elapsed: {elapsed}{budget_str}")) |
| 55 | } else { |
| 56 | CommandResult::message( |
| 57 | "No goal set. Use /goal <objective> [budget: N] to set one.\n\ |
| 58 | /goal clear — remove the current goal.", |
| 59 | ) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// Parse optional token budget from goal text: "Implement login | budget: 50000" |
| 66 | fn parse_goal_budget(text: &str) -> (String, Option<u32>) { |
| 67 | if let Some((obj, rest)) = text.split_once(" | budget:") { |
| 68 | let budget = rest |
| 69 | .split_whitespace() |
| 70 | .next() |
| 71 | .and_then(|s| s.parse::<u32>().ok()); |
| 72 | (obj.trim().to_string(), budget) |
| 73 | } else if let Some((obj, rest)) = text.split_once("budget:") { |
| 74 | let budget = rest |
| 75 | .split_whitespace() |
| 76 | .next() |
| 77 | .and_then(|s| s.parse::<u32>().ok()); |
| 78 | (obj.trim().to_string(), budget) |
| 79 | } else { |
| 80 | (text.trim().to_string(), None) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | #[cfg(test)] |
| 85 | mod tests { |
| 86 | use super::*; |
| 87 | use crate::config::Config; |
| 88 | use crate::tui::app::{App, TuiOptions}; |
| 89 | use std::path::PathBuf; |
| 90 | |
| 91 | fn create_test_app() -> App { |
| 92 | let options = TuiOptions { |
| 93 | model: "deepseek-v4-flash".to_string(), |
| 94 | workspace: PathBuf::from("."), |
| 95 | config_path: None, |
| 96 | config_profile: None, |
| 97 | allow_shell: false, |
| 98 | use_alt_screen: true, |
| 99 | use_mouse_capture: false, |
| 100 | use_bracketed_paste: true, |
| 101 | max_subagents: 1, |
| 102 | skills_dir: PathBuf::from("."), |
| 103 | memory_path: PathBuf::from("memory.md"), |
| 104 | notes_path: PathBuf::from("notes.txt"), |
| 105 | mcp_config_path: PathBuf::from("mcp.json"), |
| 106 | use_memory: false, |
| 107 | start_in_agent_mode: true, |
| 108 | skip_onboarding: true, |
| 109 | yolo: false, |
| 110 | resume_session_id: None, |
| 111 | initial_input: None, |
| 112 | }; |
| 113 | App::new(options, &Config::default()) |
| 114 | } |
| 115 | |
| 116 | #[test] |
| 117 | fn test_set_goal() { |
| 118 | let mut app = create_test_app(); |
| 119 | let result = goal(&mut app, Some("Fix the login bug")); |
| 120 | assert!(result.message.unwrap().contains("Goal set")); |
| 121 | assert_eq!( |
| 122 | app.goal.goal_objective.as_deref(), |
| 123 | Some("Fix the login bug") |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn test_set_goal_with_budget() { |
| 129 | let mut app = create_test_app(); |
| 130 | let _ = goal(&mut app, Some("Refactor auth | budget: 50000")); |
| 131 | assert_eq!(app.goal.goal_objective.as_deref(), Some("Refactor auth")); |
| 132 | assert_eq!(app.goal.goal_token_budget, Some(50_000)); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn test_clear_goal() { |
| 137 | let mut app = create_test_app(); |
| 138 | app.goal.goal_objective = Some("test".to_string()); |
| 139 | let _ = goal(&mut app, Some("clear")); |
| 140 | assert!(app.goal.goal_objective.is_none()); |
| 141 | assert!(app.goal.goal_token_budget.is_none()); |
| 142 | } |
| 143 | |
| 144 | #[test] |
| 145 | fn test_show_goal_when_none() { |
| 146 | let mut app = create_test_app(); |
| 147 | let result = goal(&mut app, None); |
| 148 | assert!(result.message.unwrap().contains("No goal set")); |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn test_parse_budget() { |
| 153 | assert_eq!( |
| 154 | parse_goal_budget("Do a thing | budget: 50000"), |
| 155 | ("Do a thing".to_string(), Some(50_000)) |
| 156 | ); |
| 157 | assert_eq!( |
| 158 | parse_goal_budget("Simple goal"), |
| 159 | ("Simple goal".to_string(), None) |
| 160 | ); |
| 161 | assert_eq!( |
| 162 | parse_goal_budget("Goal budget:1000"), |
| 163 | ("Goal".to_string(), Some(1000)) |
| 164 | ); |
| 165 | } |
| 166 | } |
| 167 |