Merge pull request #2306 from idling11/feat/goal-to-hunt
Feat/Rename /goal → /hunt with HuntVerdict + trophy cards (#2092)
This commit is contained in:
+236
-129
@@ -1,61 +1,51 @@
|
||||
//! /goal command — set a session objective with token budget and progress tracking.
|
||||
//! /hunt command — declare a quarry with token budget and verdict tracking (#2092).
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use std::io::Write;
|
||||
|
||||
use crate::tui::app::{App, AppAction, HuntVerdict};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Set or show the current goal
|
||||
pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
/// Declare, show, or close a hunt
|
||||
pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
match arg {
|
||||
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}"))
|
||||
app.hunt.quarry = None;
|
||||
app.hunt.token_budget = None;
|
||||
app.hunt.started_at = None;
|
||||
app.hunt.verdict = HuntVerdict::default();
|
||||
CommandResult::message("Hunt cleared.")
|
||||
}
|
||||
Some("done") | Some("complete") | Some("hunted") => close_hunt(app, HuntVerdict::Hunted),
|
||||
Some("wound") | Some("wounded") => close_hunt(app, HuntVerdict::Wounded),
|
||||
Some("escape") | Some("escaped") => close_hunt(app, HuntVerdict::Escaped),
|
||||
Some(text) if !text.is_empty() => {
|
||||
// Parse optional budget: "/goal Implement login | budget: 50000"
|
||||
let (objective, budget) = parse_goal_budget(text);
|
||||
let objective = objective.trim().to_string();
|
||||
let (objective, budget) = parse_hunt_budget(text);
|
||||
if objective.is_empty() || objective.chars().all(|c| c == '|') {
|
||||
return CommandResult::error("Usage: /goal <objective> [budget: N]");
|
||||
return CommandResult::error("Usage: /hunt <quarry> [budget: N]");
|
||||
}
|
||||
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;
|
||||
app.hunt.quarry = Some(objective.clone());
|
||||
app.hunt.token_budget = budget;
|
||||
app.hunt.started_at = Some(std::time::Instant::now());
|
||||
app.hunt.verdict = HuntVerdict::Hunting;
|
||||
let budget_str = budget
|
||||
.map(|b| format!(" (budget: {b} tokens)"))
|
||||
.unwrap_or_default();
|
||||
CommandResult::with_message_and_action(
|
||||
format!("Goal set: \"{objective}\"{budget_str} — tracking progress."),
|
||||
format!("Hunt set: \"{objective}\"{budget_str} — tracking progress."),
|
||||
AppAction::SendMessage(objective),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Show current goal
|
||||
if let Some(ref obj) = app.goal.goal_objective {
|
||||
// #447: render long elapsed times as `2d 3h` rather
|
||||
// than Rust's default Debug `Duration` (which produces
|
||||
// `188415.234s` or similar for multi-day goals).
|
||||
if let Some(ref obj) = app.hunt.quarry {
|
||||
let elapsed = app
|
||||
.goal
|
||||
.goal_started_at
|
||||
.hunt
|
||||
.started_at
|
||||
.map(|t| crate::tui::notifications::humanize_duration(t.elapsed()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let budget_str = app
|
||||
.goal
|
||||
.goal_token_budget
|
||||
.hunt
|
||||
.token_budget
|
||||
.map(|b| {
|
||||
let used = app.session.total_conversation_tokens;
|
||||
let pct = if b > 0 {
|
||||
@@ -66,26 +56,61 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
format!(" | tokens: {used}/{b} ({pct:.0}%)")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let status = if app.goal.goal_completed {
|
||||
" [COMPLETED]"
|
||||
} else {
|
||||
""
|
||||
let verdict_label = match app.hunt.verdict {
|
||||
HuntVerdict::Hunting => "[HUNTING]",
|
||||
HuntVerdict::Hunted => "[HUNTED]",
|
||||
HuntVerdict::Wounded => "[WOUNDED]",
|
||||
HuntVerdict::Escaped => "[ESCAPED]",
|
||||
};
|
||||
CommandResult::message(format!(
|
||||
"Goal{status}: \"{obj}\" — elapsed: {elapsed}{budget_str}"
|
||||
"Hunt {verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}"
|
||||
))
|
||||
} else {
|
||||
CommandResult::message(
|
||||
"No goal set. Use /goal <objective> [budget: N] to set one.\n\
|
||||
/goal clear — remove the current goal.",
|
||||
"No hunt set. Use /hunt <quarry> [budget: N] to declare one.\n\
|
||||
/hunt hunted — mark complete\n\
|
||||
/hunt wounded — mark interrupted (resumable)\n\
|
||||
/hunt escaped — mark abandoned\n\
|
||||
/hunt clear — remove the current hunt.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse optional token budget from goal text: "Implement login | budget: 50000"
|
||||
fn parse_goal_budget(text: &str) -> (String, Option<u32>) {
|
||||
fn close_hunt(app: &mut App, verdict: HuntVerdict) -> CommandResult {
|
||||
if app.hunt.quarry.as_deref().is_none_or(str::is_empty) {
|
||||
return CommandResult::error("No hunt set. Use /hunt <quarry> [budget: N] first.");
|
||||
}
|
||||
|
||||
let prev = app.hunt.verdict;
|
||||
let should_write_trophy = prev != verdict || !matches!(verdict, HuntVerdict::Hunted);
|
||||
if should_write_trophy {
|
||||
if let Err(err) = write_trophy_card(app, verdict) {
|
||||
return CommandResult::error(err);
|
||||
}
|
||||
}
|
||||
app.hunt.verdict = verdict;
|
||||
|
||||
match verdict {
|
||||
HuntVerdict::Hunted => {
|
||||
let elapsed = app
|
||||
.hunt
|
||||
.started_at
|
||||
.map(|t| crate::tui::notifications::humanize_duration(t.elapsed()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}"))
|
||||
}
|
||||
HuntVerdict::Wounded => {
|
||||
CommandResult::message("Hunt wounded — progress saved, can be resumed.")
|
||||
}
|
||||
HuntVerdict::Escaped => CommandResult::message("Hunt escaped — quarry abandoned."),
|
||||
HuntVerdict::Hunting => CommandResult::message("Hunt resumed."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse text like "Implement login | budget: 50000" into (objective, budget).
|
||||
fn parse_hunt_budget(text: &str) -> (String, Option<u32>) {
|
||||
if let Some((obj, rest)) = text.split_once(" | budget:") {
|
||||
let budget = rest
|
||||
.split_whitespace()
|
||||
@@ -103,18 +128,114 @@ fn parse_goal_budget(text: &str) -> (String, Option<u32>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a trophy card to `~/.codewhale/trophies/<date>-<time>-<slug>.md`
|
||||
/// for the current hunt verdict (#2092).
|
||||
fn write_trophy_card(app: &App, verdict: HuntVerdict) -> Result<std::path::PathBuf, String> {
|
||||
let quarry = app
|
||||
.hunt
|
||||
.quarry
|
||||
.as_deref()
|
||||
.ok_or_else(|| "No hunt set. Use /hunt <quarry> [budget: N] first.".to_string())?;
|
||||
// Collapse consecutive non-alphanumeric chars into a single '-'
|
||||
let mut slug = String::new();
|
||||
let mut last_dash = false;
|
||||
for c in quarry.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
slug.push(c.to_ascii_lowercase());
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
slug.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_matches('-');
|
||||
if slug.is_empty() {
|
||||
return Err(
|
||||
"Cannot write trophy card: hunt quarry has no filename-safe characters.".into(),
|
||||
);
|
||||
}
|
||||
let now = chrono::Local::now();
|
||||
let time = now.format("%H%M%S");
|
||||
let date = now.format("%Y-%m-%d");
|
||||
let date_str = date.to_string();
|
||||
let now_str = now.to_string();
|
||||
let dir = codewhale_config::resolve_state_dir("trophies")
|
||||
.map_err(|err| format!("Could not resolve trophy directory: {err}"))?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|err| format!("Could not create trophy directory {}: {err}", dir.display()))?;
|
||||
// Include time in filename to avoid collisions on same-date hunts.
|
||||
let filename = format!("{date}-{time}-{slug}.md");
|
||||
let path = dir.join(&filename);
|
||||
|
||||
let elapsed = app
|
||||
.hunt
|
||||
.started_at
|
||||
.as_ref()
|
||||
.map(|t| crate::tui::notifications::humanize_duration(t.elapsed()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let verdict_str = match verdict {
|
||||
HuntVerdict::Hunting => "hunting",
|
||||
HuntVerdict::Hunted => "hunted",
|
||||
HuntVerdict::Wounded => "wounded",
|
||||
HuntVerdict::Escaped => "escaped",
|
||||
};
|
||||
let tokens = app.session.total_conversation_tokens;
|
||||
let budget_str = app
|
||||
.hunt
|
||||
.token_budget
|
||||
.map(|b| format!("{b}"))
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let mut f = std::fs::File::create(&path)
|
||||
.map_err(|err| format!("Could not create trophy card {}: {err}", path.display()))?;
|
||||
write_trophy_card_contents(
|
||||
&mut f,
|
||||
TrophyCard {
|
||||
quarry,
|
||||
verdict: verdict_str,
|
||||
date: &date_str,
|
||||
elapsed: &elapsed,
|
||||
tokens,
|
||||
budget: &budget_str,
|
||||
now: &now_str,
|
||||
},
|
||||
)
|
||||
.map_err(|err| format!("Could not write trophy card {}: {err}", path.display()))?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
struct TrophyCard<'a> {
|
||||
quarry: &'a str,
|
||||
verdict: &'a str,
|
||||
date: &'a str,
|
||||
elapsed: &'a str,
|
||||
tokens: u32,
|
||||
budget: &'a str,
|
||||
now: &'a str,
|
||||
}
|
||||
|
||||
fn write_trophy_card_contents(mut f: impl Write, card: TrophyCard<'_>) -> std::io::Result<()> {
|
||||
writeln!(f, "# Trophy: {}", card.quarry)?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "- **Verdict**: {}", card.verdict)?;
|
||||
writeln!(f, "- **Date**: {}", card.date)?;
|
||||
writeln!(f, "- **Elapsed**: {}", card.elapsed)?;
|
||||
writeln!(f, "- **Tokens used**: {}", card.tokens)?;
|
||||
writeln!(f, "- **Token budget**: {}", card.budget)?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "_Generated by CodeWhale `/hunt` — {}_", card.now)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::AppAction;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
let options = crate::tui::app::TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: std::path::PathBuf::from("/tmp/test-workspace"),
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
@@ -122,29 +243,27 @@ mod tests {
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
skills_dir: std::path::PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: std::path::PathBuf::from("memory.md"),
|
||||
notes_path: std::path::PathBuf::from("notes.txt"),
|
||||
mcp_config_path: std::path::PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: true,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
resume_session_id: None,
|
||||
yolo: false,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
let config = crate::config::Config::default();
|
||||
App::new(options, &config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_goal() {
|
||||
fn test_set_hunt() {
|
||||
let mut app = create_test_app();
|
||||
let result = goal(&mut app, Some("Fix the login bug"));
|
||||
assert!(result.message.unwrap().contains("Goal set"));
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
Some("Fix the login bug")
|
||||
);
|
||||
let result = hunt(&mut app, Some("Fix the login bug"));
|
||||
assert!(result.message.unwrap().contains("Hunt set"));
|
||||
assert_eq!(app.hunt.quarry.as_deref(), Some("Fix the login bug"));
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::SendMessage(msg)) if msg == "Fix the login bug"
|
||||
@@ -152,105 +271,93 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_goal_dispatched_as_sendmessage() {
|
||||
fn test_hunt_without_argument_shows_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = crate::commands::execute("/goal Implement login flow", &mut app);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.is_some_and(|message| message.contains("Goal set"))
|
||||
);
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::SendMessage(content))
|
||||
if content == *"Implement login flow"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_goal_without_argument_shows_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = crate::commands::execute("/goal", &mut app);
|
||||
let result = hunt(&mut app, None);
|
||||
assert!(result.action.is_none());
|
||||
assert!(matches!(result.message.as_deref(), Some(value) if value.contains("No goal set")));
|
||||
assert!(result.message.as_deref().unwrap().contains("No hunt set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_goal_with_budget() {
|
||||
fn test_set_hunt_with_budget() {
|
||||
let mut app = create_test_app();
|
||||
let _ = goal(&mut app, Some("Refactor auth | budget: 50000"));
|
||||
assert_eq!(app.goal.goal_objective.as_deref(), Some("Refactor auth"));
|
||||
assert_eq!(app.goal.goal_token_budget, Some(50_000));
|
||||
assert!(app.goal.goal_started_at.is_some());
|
||||
let _ = hunt(&mut app, Some("Refactor auth | budget: 50000"));
|
||||
assert_eq!(app.hunt.quarry.as_deref(), Some("Refactor auth"));
|
||||
assert_eq!(app.hunt.token_budget, Some(50_000));
|
||||
assert!(app.hunt.started_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_goal_rejects_budget_only_objective() {
|
||||
fn test_set_hunt_rejects_budget_only_objective() {
|
||||
let mut app = create_test_app();
|
||||
app.goal.goal_objective = Some("existing objective".to_string());
|
||||
app.goal.goal_token_budget = Some(10_000);
|
||||
app.hunt.quarry = Some("existing objective".to_string());
|
||||
app.hunt.token_budget = Some(10_000);
|
||||
|
||||
let result = crate::commands::execute("/goal budget: 50000", &mut app);
|
||||
let result = hunt(&mut app, Some("budget: 50000"));
|
||||
assert!(result.is_error);
|
||||
assert!(result.action.is_none());
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Usage: /goal")
|
||||
.contains("Usage: /hunt")
|
||||
);
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
Some("existing objective")
|
||||
);
|
||||
assert_eq!(app.goal.goal_token_budget, Some(10_000));
|
||||
|
||||
let pipe_result = crate::commands::execute("/goal | budget: 50000", &mut app);
|
||||
assert!(pipe_result.is_error);
|
||||
assert!(pipe_result.action.is_none());
|
||||
assert!(
|
||||
pipe_result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Usage: /goal")
|
||||
);
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
Some("existing objective")
|
||||
);
|
||||
assert_eq!(app.goal.goal_token_budget, Some(10_000));
|
||||
assert_eq!(app.hunt.quarry.as_deref(), Some("existing objective"));
|
||||
assert_eq!(app.hunt.token_budget, Some(10_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_goal() {
|
||||
fn test_clear_hunt() {
|
||||
let mut app = create_test_app();
|
||||
app.goal.goal_objective = Some("test".to_string());
|
||||
let _ = goal(&mut app, Some("clear"));
|
||||
assert!(app.goal.goal_objective.is_none());
|
||||
assert!(app.goal.goal_token_budget.is_none());
|
||||
app.hunt.quarry = Some("test".to_string());
|
||||
let _ = hunt(&mut app, Some("clear"));
|
||||
assert!(app.hunt.quarry.is_none());
|
||||
assert!(app.hunt.token_budget.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_goal_when_none() {
|
||||
fn test_verdict_requires_existing_hunt() {
|
||||
let mut app = create_test_app();
|
||||
let result = goal(&mut app, None);
|
||||
assert!(result.message.unwrap().contains("No goal set"));
|
||||
|
||||
let result = hunt(&mut app, Some("wounded"));
|
||||
|
||||
assert!(result.is_error);
|
||||
assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
|
||||
assert!(app.hunt.quarry.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_failed_trophy_write_does_not_mutate_verdict() {
|
||||
let mut app = create_test_app();
|
||||
app.hunt.quarry = Some("!!!".to_string());
|
||||
app.hunt.verdict = HuntVerdict::Hunting;
|
||||
|
||||
let result = hunt(&mut app, Some("escaped"));
|
||||
|
||||
assert!(result.is_error);
|
||||
assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
|
||||
assert_eq!(app.hunt.quarry.as_deref(), Some("!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_hunt_when_none() {
|
||||
let mut app = create_test_app();
|
||||
let result = hunt(&mut app, None);
|
||||
assert!(result.message.unwrap().contains("No hunt set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_budget() {
|
||||
assert_eq!(
|
||||
parse_goal_budget("Do a thing | budget: 50000"),
|
||||
parse_hunt_budget("Do a thing | budget: 50000"),
|
||||
("Do a thing".to_string(), Some(50_000))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_goal_budget("Simple goal"),
|
||||
parse_hunt_budget("Simple goal"),
|
||||
("Simple goal".to_string(), None)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_goal_budget("Goal budget:1000"),
|
||||
parse_hunt_budget("Goal budget:1000"),
|
||||
("Goal".to_string(), Some(1000))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,9 +463,9 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description_id: MessageId::CmdShareDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
name: "goal",
|
||||
aliases: &["mubiao"],
|
||||
usage: "/goal [objective] [budget: N]",
|
||||
name: "hunt",
|
||||
aliases: &["goal", "mubiao", "狩猎"],
|
||||
usage: "/hunt [quarry] [budget: N]",
|
||||
description_id: MessageId::CmdGoalDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
@@ -658,7 +658,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"init" => init::init(app),
|
||||
"lsp" => config::lsp_command(app, arg),
|
||||
"share" => share::share(app, arg),
|
||||
"goal" | "mubiao" => goal::goal(app, arg),
|
||||
"goal" | "hunt" | "mubiao" | "狩猎" => goal::hunt(app, arg),
|
||||
|
||||
// Skills commands
|
||||
"skills" | "jinengliebiao" => skills::list_skills(app, arg),
|
||||
@@ -836,11 +836,11 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String {
|
||||
if let Some(focus) = focus {
|
||||
let _ = writeln!(out, "- Requested relay focus: {focus}");
|
||||
}
|
||||
if let Some(goal) = app.goal.goal_objective.as_deref() {
|
||||
let _ = writeln!(out, "- Goal: {goal}");
|
||||
if let Some(quarry) = app.hunt.quarry.as_deref() {
|
||||
let _ = writeln!(out, "- Hunt quarry: {quarry}");
|
||||
}
|
||||
if let Some(budget) = app.goal.goal_token_budget {
|
||||
let _ = writeln!(out, "- Goal token budget: {budget}");
|
||||
if let Some(budget) = app.hunt.token_budget {
|
||||
let _ = writeln!(out, "- Hunt token budget: {budget}");
|
||||
}
|
||||
if app.cycle_count > 0 {
|
||||
let _ = writeln!(out, "- Cycle count: {}", app.cycle_count);
|
||||
@@ -1175,8 +1175,8 @@ mod tests {
|
||||
#[test]
|
||||
fn relay_slash_command_routes_to_session_relay_instruction() {
|
||||
let mut app = create_test_app();
|
||||
app.goal.goal_objective = Some("Unify the work surface".to_string());
|
||||
app.goal.goal_token_budget = Some(12_000);
|
||||
app.hunt.quarry = Some("Unify the work surface".to_string());
|
||||
app.hunt.token_budget = Some(12_000);
|
||||
app.cycle_count = 2;
|
||||
{
|
||||
let mut todos = app.todos.try_lock().expect("todo lock");
|
||||
@@ -1211,8 +1211,8 @@ mod tests {
|
||||
assert!(message.contains("Write or update `.deepseek/handoff.md`"));
|
||||
assert!(message.contains("# Session relay"));
|
||||
assert!(message.contains("Requested relay focus: verify install"));
|
||||
assert!(message.contains("Goal: Unify the work surface"));
|
||||
assert!(message.contains("Goal token budget: 12000"));
|
||||
assert!(message.contains("Hunt quarry: Unify the work surface"));
|
||||
assert!(message.contains("Hunt token budget: 12000"));
|
||||
assert!(message.contains("Cycle count: 2"));
|
||||
assert!(message.contains("Work checklist (primary progress surface, 50% complete)"));
|
||||
assert!(message.contains("#1 [completed] inspect workspace"));
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::app::{App, AppAction, HuntVerdict};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
@@ -192,14 +192,16 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
|
||||
for (name, content) in &user_commands {
|
||||
if name == command {
|
||||
let (metadata, body) = parse_frontmatter(content);
|
||||
app.goal.goal_objective = None;
|
||||
app.goal.goal_started_at = None;
|
||||
app.hunt.quarry = None;
|
||||
app.hunt.started_at = None;
|
||||
app.hunt.verdict = HuntVerdict::Hunting;
|
||||
app.hunt.token_budget = None;
|
||||
app.active_allowed_tools = None;
|
||||
for (key, value) in &metadata {
|
||||
match key.as_str() {
|
||||
"description" => {
|
||||
app.goal.goal_objective = Some(value.clone());
|
||||
app.goal.goal_started_at = Some(std::time::Instant::now());
|
||||
app.hunt.quarry = Some(value.clone());
|
||||
app.hunt.started_at = Some(std::time::Instant::now());
|
||||
}
|
||||
"allowed-tools" => {
|
||||
app.active_allowed_tools = Some(parse_allowed_tools(value));
|
||||
@@ -603,13 +605,19 @@ mod tests {
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
|
||||
assert_eq!(app.goal.goal_objective.as_deref(), Some("Scan repos"));
|
||||
assert!(app.goal.goal_started_at.is_some());
|
||||
assert_eq!(app.hunt.quarry.as_deref(), Some("Scan repos"));
|
||||
assert!(app.hunt.started_at.is_some());
|
||||
assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting);
|
||||
assert_eq!(app.hunt.token_budget, None);
|
||||
assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
|
||||
|
||||
app.hunt.verdict = crate::tui::app::HuntVerdict::Escaped;
|
||||
app.hunt.token_budget = Some(42);
|
||||
let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
|
||||
assert_eq!(app.goal.goal_objective, None);
|
||||
assert_eq!(app.goal.goal_started_at, None);
|
||||
assert_eq!(app.hunt.quarry, None);
|
||||
assert_eq!(app.hunt.started_at, None);
|
||||
assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting);
|
||||
assert_eq!(app.hunt.token_budget, None);
|
||||
assert_eq!(app.active_allowed_tools, None);
|
||||
}
|
||||
|
||||
@@ -628,7 +636,7 @@ mod tests {
|
||||
let mut app = App::new(test_options(ws.clone()), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
app.hunt.quarry.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
let commands = load_user_commands(Some(&ws));
|
||||
|
||||
@@ -1065,7 +1065,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
&& !goal_objective.trim().is_empty()
|
||||
{
|
||||
full_prompt = format!(
|
||||
"{full_prompt}\n\n## Current Session Goal\n\n<session_goal>\n{}\n</session_goal>",
|
||||
"{full_prompt}\n\n## Current Hunt\n\n<session_goal>\n{}\n</session_goal>",
|
||||
goal_objective.trim()
|
||||
);
|
||||
}
|
||||
@@ -2120,7 +2120,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert!(!prompt.contains("<session_goal>"));
|
||||
assert!(!prompt.contains("## Current Session Goal"));
|
||||
assert!(!prompt.contains("## Current Hunt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -1001,13 +1002,29 @@ impl Default for ViewportState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Goal tracking state (#397).
|
||||
/// Verdict for a hunt (#2092).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HuntVerdict {
|
||||
Hunting,
|
||||
Hunted,
|
||||
Wounded,
|
||||
Escaped,
|
||||
}
|
||||
|
||||
impl Default for HuntVerdict {
|
||||
fn default() -> Self {
|
||||
Self::Hunting
|
||||
}
|
||||
}
|
||||
|
||||
/// Hunt tracking state (#2092 — was GoalState).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GoalState {
|
||||
pub goal_objective: Option<String>,
|
||||
pub goal_token_budget: Option<u32>,
|
||||
pub goal_started_at: Option<Instant>,
|
||||
pub goal_completed: bool,
|
||||
pub struct HuntState {
|
||||
pub quarry: Option<String>,
|
||||
pub token_budget: Option<u32>,
|
||||
pub started_at: Option<Instant>,
|
||||
pub verdict: HuntVerdict,
|
||||
}
|
||||
|
||||
/// Session cost and token telemetry state.
|
||||
@@ -1105,7 +1122,7 @@ pub struct App {
|
||||
/// Viewport sub-state (scroll, cache, selection).
|
||||
pub viewport: ViewportState,
|
||||
/// Goal sub-state.
|
||||
pub goal: GoalState,
|
||||
pub hunt: HuntState,
|
||||
/// Session sub-state (cost, tokens, telemetry).
|
||||
pub session: SessionState,
|
||||
/// Active tool restriction from custom slash command frontmatter.
|
||||
@@ -1861,7 +1878,7 @@ impl App {
|
||||
selection_anchor: None,
|
||||
},
|
||||
viewport: ViewportState::default(),
|
||||
goal: GoalState::default(),
|
||||
hunt: HuntState::default(),
|
||||
session: SessionState::default(),
|
||||
active_allowed_tools: None,
|
||||
history: Vec::new(),
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
use std::fmt::Write;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::tui::app::HuntVerdict;
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
@@ -228,10 +230,10 @@ impl SidebarWorkSummary {
|
||||
|
||||
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,
|
||||
goal_objective: app.hunt.quarry.clone(),
|
||||
goal_token_budget: app.hunt.token_budget,
|
||||
goal_completed: app.hunt.verdict == HuntVerdict::Hunted,
|
||||
goal_started_at: app.hunt.started_at,
|
||||
tokens_used: app.session.total_conversation_tokens,
|
||||
cycle_count: app.cycle_count,
|
||||
..SidebarWorkSummary::default()
|
||||
|
||||
@@ -64,6 +64,7 @@ use crate::task_manager::{
|
||||
};
|
||||
use crate::tools::spec::{RuntimeToolServices, ToolResult};
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tui::app::HuntVerdict;
|
||||
use crate::tui::auto_router;
|
||||
use crate::tui::color_compat::ColorCompatBackend;
|
||||
use crate::tui::command_palette::{
|
||||
@@ -743,9 +744,9 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
todos: app.todos.clone(),
|
||||
plan_state: app.plan_state.clone(),
|
||||
goal_state: crate::tools::goal::new_shared_goal_state_from_host(
|
||||
app.goal.goal_objective.clone(),
|
||||
app.goal.goal_token_budget,
|
||||
app.goal.goal_completed,
|
||||
app.hunt.quarry.clone(),
|
||||
app.hunt.token_budget,
|
||||
app.hunt.verdict == HuntVerdict::Hunted,
|
||||
),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
allowed_tools: app.active_allowed_tools.clone(),
|
||||
@@ -769,7 +770,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
memory_path: config.memory_path(),
|
||||
vision_config: config.vision_model_config(),
|
||||
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
|
||||
goal_objective: app.goal.goal_objective.clone(),
|
||||
goal_objective: app.hunt.quarry.clone(),
|
||||
locale_tag: app.ui_locale.tag().to_string(),
|
||||
workshop: config.workshop.clone(),
|
||||
search_provider: config.search_provider(),
|
||||
@@ -4389,7 +4390,7 @@ async fn dispatch_user_message(
|
||||
None,
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: app.goal.goal_objective.as_deref(),
|
||||
goal_objective: app.hunt.quarry.as_deref(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
locale_tag: app.ui_locale.tag(),
|
||||
translation_enabled: app.translation_enabled,
|
||||
@@ -4482,7 +4483,7 @@ async fn dispatch_user_message(
|
||||
content,
|
||||
mode: app.mode,
|
||||
model: effective_model,
|
||||
goal_objective: app.goal.goal_objective.clone(),
|
||||
goal_objective: app.hunt.quarry.clone(),
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
reasoning_effort_auto: auto_controls_reasoning,
|
||||
auto_model: app.auto_model,
|
||||
|
||||
Reference in New Issue
Block a user