From a089f37dbe2a5f8bbf86b3cb642f562de59533c6 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Sun, 17 May 2026 13:07:18 +0200 Subject: [PATCH 1/4] refactor: replace Command enum with trait-based registry + sync closures Replace the monolithic Command enum and CommandDispatcher with a trait-based CommandRegistry that supports both async Command impls and lightweight SyncCommand closures. Architecture: - Add Command trait, SyncCommand wrapper, AliasCommand, and CommandRegistry to registry.rs - Extract CommandContext from the old CommandDispatcher - 18 sync commands converted from struct+impl Command to closures registered via register_sync()/register_sync_with_usage() - 6 async commands kept as impl Command (need provider.lock().await): CompactCommand, InitCommand, ModelsCommand, ModelCommand, TimeoutCommand, RetriesCommand Benefits: - Eliminates ~18 struct definitions and impl Command blocks - Command registration is declarative and centralized in build_registry() - Sync commands no longer need Pin> boilerplate - Individual command files are simpler (just logic functions) - Net -231 lines across the codebase (789 added, 1020 removed) All 87 tests pass. Clippy clean. Formatted with cargo fmt. --- src/agent/mod.rs | 307 ++++---- src/agent/tools.rs | 31 +- src/commands/command.rs | 39 +- src/commands/compact.rs | 40 ++ src/commands/config_settings.rs | 233 +++++++ src/commands/files.rs | 2 + src/commands/help.rs | 5 +- src/commands/init.rs | 36 + src/commands/mod.rs | 1160 +++++++++---------------------- src/commands/mode.rs | 36 + src/commands/models.rs | 85 ++- src/commands/registry.rs | 466 +++++++++++++ src/commands/rename.rs | 11 + src/commands/sessions.rs | 1 + src/commands/settings.rs | 10 +- src/commands/skill.rs | 39 ++ src/main.rs | 10 +- src/ui/input.rs | 44 +- 18 files changed, 1535 insertions(+), 1020 deletions(-) create mode 100644 src/commands/config_settings.rs create mode 100644 src/commands/mode.rs create mode 100644 src/commands/registry.rs create mode 100644 src/commands/rename.rs diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 5979e6a..bad774c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -18,6 +18,7 @@ use tokio::sync::Mutex; use tinyharness_lib::{ config::load_settings, + context::WorkspaceContext, mode::AgentMode, provider::{Message, Provider, Role}, session::{Session, SessionStore}, @@ -27,7 +28,7 @@ use tinyharness_lib::{ use crate::style::*; use crate::{ - commands::{CommandDispatcher, CommandResult, init}, + commands::{CommandContext, CommandResult, build_registry, init}, ui::input::CommandHelper, }; @@ -43,10 +44,13 @@ pub async fn run_agent_loop( provider: Arc>, tool_manager: ToolManager, messages: &mut Vec, - dispatcher: &mut CommandDispatcher, + ctx: &mut CommandContext, session: &mut Session, interrupted: &Arc, ) -> Result<(), Box> { + // Build the command registry once at startup + let registry = build_registry(); + let mut stdout = io::stdout(); stdout.write_all( format!( @@ -115,14 +119,14 @@ pub async fn run_agent_loop( // Clear any stale interrupt flag from a previous turn. interrupted.store(false, Ordering::SeqCst); - let mode_label = dispatcher.current_mode.to_string(); - let mode_color = match dispatcher.current_mode { + let mode_label = ctx.current_mode.to_string(); + let mode_color = match ctx.current_mode { AgentMode::Casual => GREEN, AgentMode::Planning => YELLOW, AgentMode::Agent => CYAN, AgentMode::Research => ORANGE, }; - let pinned_count = dispatcher.file_context.pinned_file_count(); + let pinned_count = ctx.file_context.pinned_file_count(); let status_line = display::format_context_status( messages.len(), pinned_count, @@ -180,171 +184,160 @@ pub async fn run_agent_loop( rl.add_history_entry(&trimmed)?; if user_input.starts_with('/') { - match CommandDispatcher::parse(&user_input) { - Some(cmd) => { - match dispatcher.dispatch(cmd, messages).await { - Ok(CommandResult::Ok) => {} - Ok(CommandResult::SwitchSession(id_prefix)) => { - let store = SessionStore::default_path(); - match store.find_by_prefix(&id_prefix) { - Ok(full_id) => { - // Flush current session before switching - session.flush(); - match store.load(&full_id) { - Ok((new_session, loaded_msgs)) => { - let meta = new_session.meta(); - let name = meta.name.as_deref().unwrap_or("unnamed"); - eprintln!( - "{}Switched to session {}{}{} — {}{}{} ({} messages, {}){}", - BOLD, - BLUE, - &meta.id[..12], - RESET, - BOLD, - name, - RESET, - meta.message_count, - meta.mode, - RESET - ); - *session = new_session; - *messages = loaded_msgs; - // Update dispatcher mode and session ID to match loaded session - dispatcher.current_mode = session.meta().mode; - dispatcher.session_id = Some(session.id().to_string()); - // Ensure system prompt reflects current context - dispatcher.refresh_system_prompt(messages); - // Print loaded conversation history - print_conversation_history(messages, &mut stdout)?; - // Warn if the loaded session is near or over the context window limit - print_context_load_warning(messages, &mut stdout)?; - } - Err(e) => { - eprintln!("{}{}{}", RED, e, RESET); - } - } + match registry.dispatch(&user_input, ctx, messages).await { + Ok(CommandResult::Ok) => {} + Ok(CommandResult::SwitchSession(id_prefix)) => { + let store = SessionStore::default_path(); + match store.find_by_prefix(&id_prefix) { + Ok(full_id) => { + // Flush current session before switching + session.flush(); + match store.load(&full_id) { + Ok((new_session, loaded_msgs)) => { + let meta = new_session.meta(); + let name = meta.name.as_deref().unwrap_or("unnamed"); + eprintln!( + "{}Switched to session {}{}{} — {}{}{} ({} messages, {}){}", + BOLD, + BLUE, + &meta.id[..12], + RESET, + BOLD, + name, + RESET, + meta.message_count, + meta.mode, + RESET + ); + *session = new_session; + *messages = loaded_msgs; + // Update context mode and session ID to match loaded session + ctx.current_mode = session.meta().mode; + ctx.session_id = Some(session.id().to_string()); + // Ensure system prompt reflects current context + ctx.refresh_system_prompt(messages); + // Print loaded conversation history + print_conversation_history(messages, &mut stdout)?; + // Warn if the loaded session is near or over the context window limit + print_context_load_warning(messages, &mut stdout)?; } Err(e) => { eprintln!("{}{}{}", RED, e, RESET); } } } - Ok(CommandResult::RenameSession(new_name)) => { - session.set_name(new_name.clone()); - eprintln!("{}Session renamed to {}{}{}", BOLD, BLUE, new_name, RESET); + Err(e) => { + eprintln!("{}{}{}", RED, e, RESET); } - Ok(CommandResult::Init(result)) => match &result { - init::InitResult::Created { path } => { - eprintln!( - "{} Created {}{}{} — workspace context refreshed.{}", - GREEN, - BLUE, - path.display(), - GREEN, - RESET - ); - } - init::InitResult::Updated { path } => { - eprintln!( - "{} Updated {}{}{} — workspace context refreshed.{}", - GREEN, - BLUE, - path.display(), - GREEN, - RESET - ); - } - }, - Ok(CommandResult::SkillUse(skill_name)) => { - // Prevent duplicate activation - if dispatcher - .active_skills - .iter() - .any(|s| s.eq_ignore_ascii_case(&skill_name)) - { - eprintln!( - "{}⚠ Skill '{}' is already active.{} Use {}/unload {}{} to deactivate it.", - ORANGE, skill_name, RESET, BOLD, skill_name, RESET - ); - continue; - } - match dispatcher.skill_registry.get(&skill_name) { - Some(skill) => { - eprintln!( - "{}⚡ Skill activated: {}{}{} — {}{}", - BOLD, CYAN, skill_name, RESET, skill.description, RESET - ); - // Track the active skill - dispatcher.active_skills.push(skill.name.clone()); - // Inject a user message indicating skill activation - messages.push(Message { - role: Role::User, - content: format!("/use {}", skill_name), - tool_calls: vec![], - }); - session.append_message( - messages.last().expect("just pushed a message"), - ); - // Refresh system prompt to include the active skill - dispatcher.refresh_system_prompt(messages); - } - None => { - eprintln!( - "{}⚠ Skill '{}' not found — it may have been removed.{}", - RED, skill_name, RESET - ); - } - } + } + } + Ok(CommandResult::RenameSession(new_name)) => { + session.set_name(new_name.clone()); + eprintln!("{}Session renamed to {}{}{}", BOLD, BLUE, new_name, RESET); + } + Ok(CommandResult::Init(result)) => { + // Refresh workspace context since the project instruction file may have changed + ctx.workspace_ctx = WorkspaceContext::collect(); + ctx.refresh_system_prompt(messages); + + match &result { + init::InitResult::Created { path } => { + eprintln!( + "{} Created {}{}{} — workspace context refreshed.{}", + GREEN, + BLUE, + path.display(), + GREEN, + RESET + ); } - Ok(CommandResult::SkillUnload(skill_name)) => { - // Find and remove the skill from active list - let pos = dispatcher - .active_skills - .iter() - .position(|s| s.eq_ignore_ascii_case(&skill_name)); - match pos { - Some(idx) => { - let removed = dispatcher.active_skills.remove(idx); - eprintln!( - "{}Skill deactivated: {}{}{}{}", - BOLD, CYAN, removed, RESET, RESET - ); - // Inject a user message indicating skill deactivation - messages.push(Message { - role: Role::User, - content: format!("/unload {}", skill_name), - tool_calls: vec![], - }); - session.append_message( - messages.last().expect("just pushed a message"), - ); - // Refresh system prompt to remove the skill - dispatcher.refresh_system_prompt(messages); - } - None => { - // Should not happen since dispatch validates this - eprintln!( - "{}⚠ Skill '{}' is not active.{}", - ORANGE, skill_name, RESET - ); - } - } + init::InitResult::Updated { path } => { + eprintln!( + "{} Updated {}{}{} — workspace context refreshed.{}", + GREEN, + BLUE, + path.display(), + GREEN, + RESET + ); } - Err(e) => { - eprintln!("{}{}{}", RED, e, RESET); + } + } + Ok(CommandResult::SkillUse(skill_name)) => { + // Prevent duplicate activation + if ctx + .active_skills + .iter() + .any(|s| s.eq_ignore_ascii_case(&skill_name)) + { + eprintln!( + "{}⚠ Skill '{}' is already active.{} Use {}/unload {}{} to deactivate it.", + ORANGE, skill_name, RESET, BOLD, skill_name, RESET + ); + continue; + } + match ctx.skill_registry.get(&skill_name) { + Some(skill) => { + eprintln!( + "{}⚡ Skill activated: {}{}{} — {}{}", + BOLD, CYAN, skill_name, RESET, skill.description, RESET + ); + // Track the active skill + ctx.active_skills.push(skill.name.clone()); + // Inject a user message indicating skill activation + messages.push(Message { + role: Role::User, + content: format!("/use {}", skill_name), + tool_calls: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + // Refresh system prompt to include the active skill + ctx.refresh_system_prompt(messages); + } + None => { + eprintln!( + "{}⚠ Skill '{}' not found — it may have been removed.{}", + RED, skill_name, RESET + ); } } - if dispatcher.exit_requested { - break; + } + Ok(CommandResult::SkillUnload(skill_name)) => { + // Find and remove the skill from active list + let pos = ctx + .active_skills + .iter() + .position(|s| s.eq_ignore_ascii_case(&skill_name)); + match pos { + Some(idx) => { + let removed = ctx.active_skills.remove(idx); + eprintln!( + "{}Skill deactivated: {}{}{}{}", + BOLD, CYAN, removed, RESET, RESET + ); + // Inject a user message indicating skill deactivation + messages.push(Message { + role: Role::User, + content: format!("/unload {}", skill_name), + tool_calls: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + // Refresh system prompt to remove the skill + ctx.refresh_system_prompt(messages); + } + None => { + // Should not happen since dispatch validates this + eprintln!("{}⚠ Skill '{}' is not active.{}", ORANGE, skill_name, RESET); + } } } - None => { - eprintln!( - "{}Unknown command: {}{}{}\n Type {}/help{} for available commands.{}", - RED, BLUE, user_input, RED, BLUE, RED, RESET - ); + Err(e) => { + eprintln!("{}{}{}", RED, e, RESET); } } + if ctx.exit_requested { + break; + } continue; } @@ -362,7 +355,7 @@ pub async fn run_agent_loop( loop { // Filter tools based on current mode - let tools = tool_manager.tools_for_mode(dispatcher.current_mode); + let tools = tool_manager.tools_for_mode(ctx.current_mode); // Call the provider — it returns a receiver for streaming chunks let mut recv = { @@ -545,7 +538,7 @@ pub async fn run_agent_loop( &response_content, messages, &tool_manager, - dispatcher, + ctx, &mut stdout, &mut auto_accept, session, diff --git a/src/agent/tools.rs b/src/agent/tools.rs index 9a6c6e2..7a5c4ce 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -11,6 +11,7 @@ use tinyharness_lib::{ tools::ToolManager, }; +use crate::commands::CommandContext; use crate::commands::compact::execute_compact; use crate::style::*; use crate::ui::confirm::Confirmation; @@ -27,7 +28,7 @@ pub async fn handle_tool_calls( response_content: &str, messages: &mut Vec, tool_manager: &ToolManager, - dispatcher: &mut crate::commands::CommandDispatcher, + ctx: &mut CommandContext, stdout: &mut W, auto_accept: &mut bool, session: &mut Session, @@ -74,7 +75,7 @@ pub async fn handle_tool_calls( { match event { SignalEvent::SwitchMode { mode } => { - handle_switch_mode(mode, dispatcher, messages, session, stdout)?; + handle_switch_mode(mode, ctx, messages, session, stdout)?; } SignalEvent::Question { question, answers } => { handle_question(&question, &answers, messages, session, stdout)?; @@ -90,9 +91,9 @@ pub async fn handle_tool_calls( .await?; } SignalEvent::InvokeSkill { skill_name } => { - // Clone skill info to avoid borrowing dispatcher while calling it mutably + // Clone skill info to avoid borrowing ctx while calling it mutably let skill_result = { - let registry = &dispatcher.skill_registry; + let registry = &ctx.skill_registry; registry .get(&skill_name) .map(|s| (s.name.clone(), s.description.clone())) @@ -100,7 +101,7 @@ pub async fn handle_tool_calls( handle_invoke_skill( &skill_name, &skill_result, - dispatcher, + ctx, messages, session, stdout, @@ -450,16 +451,16 @@ async fn execute_generic_tool( session.append_message(messages.last().expect("just pushed a message")); } -/// Handle the switch_mode signal: update dispatcher and system prompt. +/// Handle the switch_mode signal: update context and system prompt. fn handle_switch_mode( new_mode: AgentMode, - dispatcher: &mut crate::commands::CommandDispatcher, + ctx: &mut CommandContext, messages: &mut Vec, session: &mut Session, stdout: &mut W, ) -> Result<(), Box> { - let old_mode = dispatcher.current_mode; - match dispatcher.switch_mode(new_mode, messages) { + let old_mode = ctx.current_mode; + match ctx.switch_mode(new_mode, messages) { Ok(()) => { session.set_mode(new_mode); @@ -675,12 +676,12 @@ async fn handle_auto_compact( /// system prompt to include the skill's instructions. /// /// `skill_result` is `Some((name, description))` if the skill was found, -/// or `None` if not found. This avoids borrowing the dispatcher while also +/// or `None` if not found. This avoids borrowing the context while also /// calling it mutably. fn handle_invoke_skill( skill_name: &str, skill_result: &Option<(String, String)>, - dispatcher: &mut crate::commands::CommandDispatcher, + ctx: &mut CommandContext, messages: &mut Vec, session: &mut Session, stdout: &mut W, @@ -688,7 +689,7 @@ fn handle_invoke_skill( match skill_result { Some((name, description)) => { // Prevent duplicate activation - if dispatcher + if ctx .active_skills .iter() .any(|s| s.eq_ignore_ascii_case(name)) @@ -720,7 +721,7 @@ fn handle_invoke_skill( stdout.flush()?; // Track the active skill - dispatcher.active_skills.push(name.clone()); + ctx.active_skills.push(name.clone()); messages.push(Message { role: Role::Tool, @@ -733,10 +734,10 @@ fn handle_invoke_skill( session.append_message(messages.last().expect("just pushed a message")); // Refresh system prompt to include the active skill - dispatcher.refresh_system_prompt(messages); + ctx.refresh_system_prompt(messages); } None => { - let available = dispatcher + let available = ctx .skill_registry .skills .iter() diff --git a/src/commands/command.rs b/src/commands/command.rs index 723aaba..f38689c 100644 --- a/src/commands/command.rs +++ b/src/commands/command.rs @@ -2,8 +2,11 @@ use std::collections::HashSet; use tinyharness_lib::config::{get_default_safe_commands, load_settings, save_settings}; +use crate::commands::registry::CommandResult; use crate::style::*; +// ── Core implementation ───────────────────────────────────────────────────── + /// Check if a command prefix would match any command in the safe list. fn matches_safe_prefix(cmd: &str, safe_commands: &[String]) -> bool { for prefix in safe_commands { @@ -17,6 +20,40 @@ fn matches_safe_prefix(cmd: &str, safe_commands: &[String]) -> bool { false } +pub fn execute(args: &str) -> Result { + let args = args.trim(); + + if args.is_empty() { + execute_list(); + return Ok(CommandResult::Ok); + } + + // Split into subcommand + rest + let sub_parts: Vec<&str> = args.splitn(2, ' ').collect(); + let sub = sub_parts[0].to_lowercase(); + let cmd_arg = sub_parts + .get(1) + .map(|s| s.trim()) + .unwrap_or("") + .trim_matches('"') + .trim_matches('\'') + .to_string(); + + match sub.as_str() { + "add" => execute_add(&cmd_arg), + "rm" | "remove" => execute_remove(&cmd_arg), + "deny" => execute_deny(&cmd_arg), + "undeny" | "allow" => execute_undeny(&cmd_arg), + "list" | "ls" => execute_list(), + "reset" => execute_reset(), + "resetdeny" => execute_reset_deny(), + "help" => execute_help(), + _ => execute_list(), + } + + Ok(CommandResult::Ok) +} + pub fn execute_add(cmd: &str) { if cmd.is_empty() { println!( @@ -201,7 +238,7 @@ pub fn format_denied_command_rows(cmds: &[&str]) -> Vec { } line.push_str(&format!("{}✕ {}{}{}", RED, cmd, RESET, RESET)); } - rows.push(line); + rows.push(line) } rows } diff --git a/src/commands/compact.rs b/src/commands/compact.rs index 9b4e50a..18f18be 100644 --- a/src/commands/compact.rs +++ b/src/commands/compact.rs @@ -4,8 +4,48 @@ use tinyharness_lib::{ token::{estimate_conversation_tokens, estimate_tokens}, }; +use crate::commands::registry::{Command, CommandContext, CommandResult}; use crate::style::*; +use std::future::Future; +use std::pin::Pin; + +// ── Command trait implementation ──────────────────────────────────────────── + +pub struct CompactCommand; + +impl Command for CompactCommand { + fn name(&self) -> &'static str { + "/compact" + } + + fn description(&self) -> &'static str { + "Summarize conversation history to free context space. Optionally specify a focus area." + } + + fn usage(&self) -> &'static str { + "/compact [focus]" + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + let focus = raw_arg.unwrap_or("").to_string(); + let provider = ctx.provider.clone(); + + Box::pin(async move { + let mut p = provider.lock().await; + execute_compact(&mut *p, messages, &focus).await?; + Ok(CommandResult::Ok) + }) + } +} + +// ── Core implementation ───────────────────────────────────────────────────── + /// Maximum characters per message when formatting for summarization. const MAX_CHARS_PER_MESSAGE: usize = 2000; diff --git a/src/commands/config_settings.rs b/src/commands/config_settings.rs new file mode 100644 index 0000000..e244e3f --- /dev/null +++ b/src/commands/config_settings.rs @@ -0,0 +1,233 @@ +use tinyharness_lib::config::{load_settings, save_settings}; +use tinyharness_lib::provider::Message; + +use crate::commands::registry::{Command, CommandContext, CommandResult}; +use crate::style::*; + +use std::future::Future; +use std::pin::Pin; + +// ── Timeout ────────────────────────────────────────────────────────────────── + +pub struct TimeoutCommand; + +impl Command for TimeoutCommand { + fn name(&self) -> &'static str { + "/timeout" + } + + fn description(&self) -> &'static str { + "Show or set the Ollama request timeout in seconds (default: 5)" + } + + fn usage(&self) -> &'static str { + "/timeout [secs]" + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + _messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + let arg = raw_arg.unwrap_or("").to_string(); + let provider = ctx.provider.clone(); + + Box::pin(async move { + if arg.is_empty() { + let settings = load_settings(); + println!( + "{}Current timeout: {}{}s{}", + BOLD, BLUE, settings.ollama_timeout_secs, RESET + ); + return Ok(CommandResult::Ok); + } + + match arg.parse::() { + Ok(secs) if secs > 0 => { + // Update settings + let mut settings = load_settings(); + settings.ollama_timeout_secs = secs; + save_settings(&settings); + // Update live provider + let mut p = provider.lock().await; + p.set_timeout(secs); + println!( + "{}Timeout set to {}{}s{}.{}", + BOLD, BLUE, secs, RESET, RESET + ); + Ok(CommandResult::Ok) + } + Ok(_) => Err("Timeout must be a positive number of seconds.".to_string()), + Err(_) => Err(format!( + "Invalid timeout value: '{}'. Use a number of seconds, e.g. /timeout 30", + arg + )), + } + }) + } +} + +// ── Retries ────────────────────────────────────────────────────────────────── + +pub struct RetriesCommand; + +impl Command for RetriesCommand { + fn name(&self) -> &'static str { + "/retries" + } + + fn description(&self) -> &'static str { + "Show or set the maximum number of Ollama request retries (default: 3)" + } + + fn usage(&self) -> &'static str { + "/retries [count]" + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + _messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + let arg = raw_arg.unwrap_or("").to_string(); + let provider = ctx.provider.clone(); + + Box::pin(async move { + if arg.is_empty() { + let settings = load_settings(); + println!( + "{}Current max retries: {}{}{}", + BOLD, BLUE, settings.ollama_max_retries, RESET + ); + return Ok(CommandResult::Ok); + } + + match arg.parse::() { + Ok(count) => { + // Update settings + let mut settings = load_settings(); + settings.ollama_max_retries = count; + save_settings(&settings); + // Update live provider + let mut p = provider.lock().await; + p.set_retries(count); + println!( + "{}Max retries set to {}{}{}.{}", + BOLD, BLUE, count, RESET, RESET + ); + Ok(CommandResult::Ok) + } + Err(_) => Err(format!( + "Invalid retries value: '{}'. Use a number, e.g. /retries 5", + arg + )), + } + }) + } +} + +// ── ContextLimit (sync) ───────────────────────────────────────────────────── + +/// Execute the /contextlimit command (sync — no provider access needed). +pub fn execute_context_limit(arg: Option<&str>) -> Result { + let a = arg.unwrap_or(""); + + if a.is_empty() { + let settings = load_settings(); + match settings.context_limit { + Some(limit) => { + println!( + "{}Context limit for warnings: {}{} tokens{}", + BOLD, BLUE, limit, RESET + ); + } + None => { + println!( + "{}Context limit: {}auto (using model default){}", + BOLD, GRAY, RESET + ); + } + } + return Ok(CommandResult::Ok); + } + + if a == "auto" || a == "default" { + // Clear the limit + let mut settings = load_settings(); + settings.context_limit = None; + save_settings(&settings); + println!( + "{}Context limit cleared. Using model default for warnings.{}", + BOLD, RESET + ); + return Ok(CommandResult::Ok); + } + + match a.parse::() { + Ok(limit) if limit > 0 => { + // Update settings + let mut settings = load_settings(); + settings.context_limit = Some(limit); + save_settings(&settings); + println!( + "{}Context limit set to {}{} tokens{} for warning calculations.{}", + BOLD, BLUE, limit, RESET, RESET + ); + Ok(CommandResult::Ok) + } + Ok(_) => Err("Context limit must be a positive number of tokens.".to_string()), + Err(_) => Err(format!( + "Invalid context limit value: '{}'. Use a number of tokens, e.g. /contextlimit 32768, or 'auto' to use model default", + a + )), + } +} + +// ── AutoAccept (sync) ─────────────────────────────────────────────────────── + +/// Execute the /autoaccept command (sync — no provider access needed). +pub fn execute_autoaccept(arg: Option<&str>) -> Result { + let a = arg.unwrap_or(""); + + if a.is_empty() { + let settings = load_settings(); + let status = if settings.auto_accept_safe_commands { + "enabled" + } else { + "disabled" + }; + let color = if settings.auto_accept_safe_commands { + GREEN + } else { + ORANGE + }; + println!( + "{}Auto-accept safe commands: {}{}{}{}", + BOLD, color, status, RESET, RESET + ); + return Ok(CommandResult::Ok); + } + + let new_value = match a.to_lowercase().as_str() { + "on" | "true" | "yes" | "1" => true, + "off" | "false" | "no" | "0" => false, + _ => { + return Err("Invalid value. Use 'on' or 'off', e.g. /autoaccept on".to_string()); + } + }; + + // Update settings + let mut settings = load_settings(); + settings.auto_accept_safe_commands = new_value; + save_settings(&settings); + let status = if new_value { "enabled" } else { "disabled" }; + let color = if new_value { GREEN } else { ORANGE }; + println!( + "{}Auto-accept safe commands set to {}{}{}{}", + BOLD, color, status, RESET, RESET + ); + + Ok(CommandResult::Ok) +} diff --git a/src/commands/files.rs b/src/commands/files.rs index d990bea..2748f80 100644 --- a/src/commands/files.rs +++ b/src/commands/files.rs @@ -170,6 +170,8 @@ impl FileContext { } } +// ── Execute helpers ───────────────────────────────────────────────────────── + /// Handle the /add command. pub fn execute_add(file_context: &mut FileContext, path: &str) { match file_context.add(path) { diff --git a/src/commands/help.rs b/src/commands/help.rs index 8420deb..5d527c7 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -1,9 +1,8 @@ -use super::CommandDispatcher; use crate::style::*; -pub fn execute() { +pub fn execute(descriptions: &[(&'static str, &'static str)]) { println!("\n{}Available commands:{}", BOLD, RESET); - for (name, desc) in CommandDispatcher::command_descriptions() { + for (name, desc) in descriptions { println!(" {}{:<20}{} {}", BLUE, name, RESET, desc); } println!(); diff --git a/src/commands/init.rs b/src/commands/init.rs index 26b56f3..0ed1604 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -3,8 +3,12 @@ use std::path::PathBuf; use tinyharness_lib::context::{PROJECT_MD_FILE_NAMES, WorkspaceContext}; use tinyharness_lib::provider::{Message, Provider, Role}; +use crate::commands::registry::{Command, CommandContext, CommandResult}; use crate::style::*; +use std::future::Future; +use std::pin::Pin; + /// Result of the `/init` command. pub enum InitResult { /// The file was created from scratch. @@ -13,6 +17,38 @@ pub enum InitResult { Updated { path: PathBuf }, } +// ── Command trait implementation ──────────────────────────────────────────── + +pub struct InitCommand; + +impl Command for InitCommand { + fn name(&self) -> &'static str { + "/init" + } + + fn description(&self) -> &'static str { + "Generate or update TINYHARNESS.md project instructions" + } + + fn execute<'a>( + &self, + _raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + let provider = ctx.provider.clone(); + let workspace_ctx = ctx.workspace_ctx.clone(); + + Box::pin(async move { + let mut p = provider.lock().await; + let result = execute_init(&mut *p, &workspace_ctx, messages).await?; + Ok(CommandResult::Init(result)) + }) + } +} + +// ── Core implementation ───────────────────────────────────────────────────── + /// Generate or update a project instruction file (TINYHARNESS.md, etc.) /// using the LLM provider to analyze the codebase. /// diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 23aee66..e3c7b88 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,866 +3,366 @@ pub mod audit; pub mod clear; pub mod command; pub mod compact; +pub mod config_settings; pub mod context; pub mod exit; pub mod files; pub mod help; pub mod init; +pub mod mode; pub mod models; +pub mod registry; +pub mod rename; pub mod sessions; pub mod settings; pub mod skill; use std::sync::Arc; +use tinyharness_lib::{context::WorkspaceContext, provider::Provider}; + use tokio::sync::Mutex; -use tinyharness_lib::{ - config::{load_settings, save_settings}, - context::WorkspaceContext, - mode::AgentMode, - provider::{Message, Provider, Role}, - skill::SkillRegistry, +use crate::commands::{ + compact::CompactCommand, + config_settings::{RetriesCommand, TimeoutCommand}, + init::InitCommand, + models::{ModelCommand, ModelsCommand}, }; -use crate::style::*; - pub use files::FileContext; pub use init::InitResult; +pub use registry::{AliasCommand, CommandContext, CommandRegistry, CommandResult}; -pub enum Command { - Help, - Clear, - Models, - Model(String), - Mode(String), - Context, - Exit, - Sessions, - SessionLoad(String), - SessionDelete(String), - Rename(String), - Settings(Option), - ApiKey(String), - Compact(String), - Add(String), - Drop(String), - Files, - DropAll, - Refresh, - Init, - Timeout(String), - Retries(String), - ContextLimit(String), - AutoAccept(String), - CommandAdd(String), - CommandRemove(String), - CommandDeny(String), - CommandUndeny(String), - CommandList, - CommandReset, - CommandResetDeny, - CommandHelp, - Audit(String), - SkillList, - SkillShow(String), - SkillUse(String), - SkillUnload(String), -} +/// Build the default command registry with all built-in commands. +pub fn build_registry() -> CommandRegistry { + let mut reg = CommandRegistry::new(); -/// Result of dispatching a command. -pub enum CommandResult { - /// Command completed normally. - Ok, - /// The user wants to switch to a different session. - SwitchSession(String), - /// The user wants to rename the current session. - RenameSession(String), - /// The /init command was run — workspace context should be refreshed. - Init(InitResult), - /// The user wants to activate a skill, injecting its instructions into the conversation. - SkillUse(String), - /// The user wants to deactivate (unload) a skill. - SkillUnload(String), -} + // ── Sync commands (simple closures, no async needed) ────────────────── -pub struct CommandDispatcher { - pub provider: Arc>, - pub exit_requested: bool, - pub current_mode: AgentMode, - pub workspace_ctx: WorkspaceContext, - pub file_context: FileContext, - pub session_id: Option, - pub skill_registry: SkillRegistry, - /// Names of currently active (loaded) skills. - pub active_skills: Vec, -} + reg.register_sync("/clear", "Clear the terminal screen", |_arg, _ctx, _msg| { + crate::commands::clear::execute(); + Ok(CommandResult::Ok) + }); -impl CommandDispatcher { - pub fn new( - provider: Arc>, - workspace_ctx: WorkspaceContext, - ) -> Self { - CommandDispatcher { - provider, - exit_requested: false, - current_mode: AgentMode::Casual, - workspace_ctx, - file_context: FileContext::new(), - session_id: None, - skill_registry: SkillRegistry::discover(), - active_skills: Vec::new(), - } - } - - /// Update the system prompt message in the conversation to reflect the current - /// mode, workspace context, and pinned files. Call this after any change that - /// affects the system prompt content (mode switch, add/drop/refresh files, etc.). - pub fn refresh_system_prompt(&self, messages: &mut [Message]) { - if let Some(sys_msg) = messages.iter_mut().find(|m| m.role == Role::System) { - sys_msg.content = self.build_system_prompt(); - } - } - - /// Switch the current mode to `new_mode`. Updates the system prompt in the - /// conversation and auto-saves the new mode to settings. - /// Returns `Ok(())` on success or an error string if the mode is unchanged/invalid. - pub fn switch_mode( - &mut self, - new_mode: AgentMode, - messages: &mut [Message], - ) -> Result<(), String> { - if new_mode == self.current_mode { - return Err(format!("Already in '{}' mode", new_mode)); - } - - self.current_mode = new_mode; - self.refresh_system_prompt(messages); - - // Auto-save mode - let mut settings = load_settings(); - settings.preferred_mode = self.current_mode; - save_settings(&settings); - - Ok(()) - } - - /// Build the system prompt for the current mode, appending workspace context, - /// pinned file content, skill index, and active skill instructions. - pub fn build_system_prompt(&self) -> String { - let mut prompt = format!( - "{}\n\n---\n{}", - self.current_mode.system_prompt(), - self.workspace_ctx.format() - ); - - // Inject pinned file content - if !self.file_context.is_empty() { - prompt.push_str(&self.file_context.format_for_prompt()); - } - - // Inject skill index for model auto-invocation - let skill_index = self.skill_registry.format_index_for_prompt(); - if !skill_index.is_empty() { - prompt.push_str("\n\n"); - prompt.push_str(&skill_index); - } - - // Inject active skill instructions - for name in &self.active_skills { - if let Some(skill) = self.skill_registry.get(name) { - prompt.push_str("\n\n"); - prompt.push_str(&self.skill_registry.format_skill_content(skill)); - } - } - - prompt - } - - pub fn parse(input: &str) -> Option { - let input = input.trim(); - if !input.starts_with('/') { - return None; - } - - let parts: Vec<&str> = input.splitn(2, ' ').collect(); - let cmd = parts[0].to_lowercase(); - let arg = parts.get(1).map(|s| s.trim().to_string()); - - match cmd.as_str() { - "/help" => Some(Command::Help), - "/clear" => Some(Command::Clear), - "/models" => Some(Command::Models), - "/model" => Some(Command::Model(arg.unwrap_or_default())), - "/mode" => Some(Command::Mode(arg.unwrap_or_default())), - "/plan" => Some(Command::Mode("planning".to_string())), - "/agent" => Some(Command::Mode("agent".to_string())), - "/research" => Some(Command::Mode("research".to_string())), - "/casual" => Some(Command::Mode("casual".to_string())), - "/context" => Some(Command::Context), - "/exit" | "/quit" => Some(Command::Exit), - "/sessions" => Some(Command::Sessions), - "/session" => { - let arg = arg.unwrap_or_default(); - // Check for subcommands: delete, load (default) - if arg.starts_with("delete ") || arg == "delete" { - let id = arg.strip_prefix("delete ").unwrap_or("").trim().to_string(); - Some(Command::SessionDelete(id)) - } else { - Some(Command::SessionLoad(arg)) - } - } - "/rename" => Some(Command::Rename(arg.unwrap_or_default())), - "/settings" => Some(Command::Settings(arg)), - "/apikey" => { - let arg = arg.unwrap_or_default(); - Some(Command::ApiKey(arg)) - } - "/compact" => Some(Command::Compact(arg.unwrap_or_default())), - "/add" => Some(Command::Add(arg.unwrap_or_default())), - "/drop" => Some(Command::Drop(arg.unwrap_or_default())), - "/dropall" => Some(Command::DropAll), - "/files" => Some(Command::Files), - "/refresh" => Some(Command::Refresh), - "/init" => Some(Command::Init), - "/timeout" => Some(Command::Timeout(arg.unwrap_or_default())), - "/retries" => Some(Command::Retries(arg.unwrap_or_default())), - "/contextlimit" => Some(Command::ContextLimit(arg.unwrap_or_default())), - "/autoaccept" => Some(Command::AutoAccept(arg.unwrap_or_default())), - "/command" => { - let arg = arg.unwrap_or_default(); - if arg.is_empty() { - // No subcommand — list all commands - Some(Command::CommandList) - } else { - // Split into subcommand + rest (e.g. "add docker" or "rm \"git stash\"") - let sub_parts: Vec<&str> = arg.splitn(2, ' ').collect(); - let sub = sub_parts[0].to_lowercase(); - let cmd_arg = sub_parts - .get(1) - .map(|s| s.trim()) - .unwrap_or("") - .trim_matches('"') - .trim_matches('\'') - .to_string(); - match sub.as_str() { - "add" => Some(Command::CommandAdd(cmd_arg)), - "rm" | "remove" => Some(Command::CommandRemove(cmd_arg)), - "deny" => Some(Command::CommandDeny(cmd_arg)), - "undeny" | "allow" => Some(Command::CommandUndeny(cmd_arg)), - "list" | "ls" => Some(Command::CommandList), - "reset" => Some(Command::CommandReset), - "resetdeny" => Some(Command::CommandResetDeny), - "help" => Some(Command::CommandHelp), - _ => Some(Command::CommandList), - } - } - } - "/audit" => Some(Command::Audit(arg.unwrap_or_default())), - "/skills" => Some(Command::SkillList), - "/skill" => { - let name = arg.unwrap_or_default(); - if name.is_empty() { - Some(Command::SkillList) - } else if let Some(skill_name) = name.strip_prefix("use ") { - // "/skill use " — activate a skill - let skill_name = skill_name.trim().to_string(); - if skill_name.is_empty() { - Some(Command::SkillList) - } else { - Some(Command::SkillUse(skill_name)) - } - } else { - // "/skill " — show skill details - Some(Command::SkillShow(name)) - } - } - "/use" => { - let name = arg.unwrap_or_default(); - if name.is_empty() { - Some(Command::SkillList) - } else { - Some(Command::SkillUse(name)) - } - } - "/unload" => { - let name = arg.unwrap_or_default(); - if name.is_empty() { - Some(Command::SkillList) - } else { - Some(Command::SkillUnload(name)) - } - } - _ => None, - } - } - - pub fn command_names() -> &'static [&'static str] { - &[ - "/help", - "/clear", - "/models", - "/model", - "/mode", - "/plan", - "/agent", - "/research", - "/casual", - "/context", - "/exit", - "/quit", - "/sessions", - "/session", - "/rename", - "/settings", - "/apikey", - "/compact", - "/add", - "/drop", - "/dropall", - "/files", - "/refresh", - "/init", - "/timeout", - "/retries", - "/contextlimit", - "/autoaccept", - "/command", - "/audit", - "/skills", - "/skill", - "/use", - "/unload", - ] - } - - pub fn command_descriptions() -> &'static [(&'static str, &'static str)] { - &[ - ("/help", "Show this help message"), - ("/clear", "Clear the terminal screen"), - ("/models", "List available models"), - ("/model ", "Switch to a different model"), - ( - "/mode [mode]", - "Show or switch mode (casual/planning/agent/research)", - ), - ( - "/plan", - "Switch to planning mode (alias for /mode planning)", - ), - ("/agent", "Switch to agent mode (alias for /mode agent)"), - ( - "/research", - "Switch to research mode (alias for /mode research)", - ), - ("/casual", "Switch to casual mode (alias for /mode casual)"), - ( - "/context", - "Show the workspace context available to the agent", - ), - ("/exit", "Exit the application"), - ("/quit", "Exit the application"), - ("/sessions", "List all saved sessions"), - ( - "/session ", - "Switch to an existing session (accepts ID prefix)", - ), - ( - "/session delete ", - "Delete a session (with confirmation)", - ), - ("/rename ", "Rename the current session"), - ( - "/settings [all]", - "Show current settings. Use 'all' to list all safe commands.", - ), - ( - "/apikey [key]", - "Set or show the Ollama API key for web search. Use /apikey clear to remove it.", - ), - ( - "/compact [focus]", - "Summarize conversation history to free context space. Optionally specify a focus area.", - ), - ( - "/add ", - "Pin a file into the AI's context so it's always available", - ), - ("/drop ", "Remove a pinned file from context"), - ("/dropall", "Remove all pinned files from context"), - ("/files", "List all pinned files in context"), - ( - "/refresh", - "Re-read all pinned files from disk (updates content)", - ), - ( - "/init", - "Generate or update TINYHARNESS.md project instructions", - ), - ( - "/timeout [secs]", - "Show or set the Ollama request timeout in seconds (default: 5)", - ), - ( - "/retries [count]", - "Show or set the maximum number of Ollama request retries (default: 3)", - ), - ( - "/contextlimit [tokens]", - "Show or set the context limit for warning calculations (default: model default)", - ), - ( - "/autoaccept [on|off]", - "Show or toggle auto-accept for safe read-only commands (default: on)", - ), - ("/command [list]", "Show auto-accepted and denied commands"), - ( - "/command add ", - "Add a command to the auto-accept list", - ), - ( - "/command rm ", - "Remove a command from the auto-accept list", - ), - ( - "/command deny ", - "Always require confirmation for a command (even if safe)", - ), - ( - "/command undeny ", - "Remove a command from the always-deny list", - ), - ("/command reset", "Reset auto-accepted commands to defaults"), - ("/command resetdeny", "Clear the always-deny list"), - ( - "/audit [last|session|clear]", - "View command execution audit log", - ), - ("/skills", "List all available skills"), - ("/skill ", "Show details and content of a skill"), - ( - "/skill use ", - "Activate a skill, injecting its instructions into the conversation", - ), - ("/use ", "Alias for /skill use "), - ("/unload ", "Deactivate a previously loaded skill"), - ] - } - - pub async fn dispatch( - &mut self, - cmd: Command, - messages: &mut Vec, - ) -> Result { - match cmd { - Command::Help => { - help::execute(); - Ok(CommandResult::Ok) - } - Command::Clear => { - clear::execute(); - Ok(CommandResult::Ok) - } - Command::Models => { - let provider = self.provider.lock().await; - models::execute_list(&*provider).await?; - Ok(CommandResult::Ok) - } - Command::Model(name) => { - if name.is_empty() { - // No argument — list available models and show current - let provider = self.provider.lock().await; - let current = provider.current_model(); - - // List models - models::execute_list(&*provider).await?; - - // Show current selection - if let Some(model) = current { - println!( - "{}Current model: {}{}{}{}", - BOLD, GREEN, model, RESET, RESET - ); - } else { - println!("{}No model currently selected.{}", ORANGE, RESET); - } - return Ok(CommandResult::Ok); - } - let mut provider = self.provider.lock().await; - models::execute_select(&mut *provider, &name).await?; - // Auto-save model - let mut settings = load_settings(); - settings.last_model = provider.current_model(); - save_settings(&settings); - Ok(CommandResult::Ok) - } - Command::Mode(mode_str) => { - if mode_str.is_empty() { - println!( - "{}Current mode: {}{}{}", - BOLD, BLUE, self.current_mode, RESET - ); - return Ok(CommandResult::Ok); - } - let new_mode: AgentMode = mode_str.parse()?; - match self.switch_mode(new_mode, messages) { - Ok(()) => { - println!("{}Switched to {} mode.{}", BOLD, BLUE, RESET); - } - Err(msg) => { - println!("{}{}{}", ORANGE, msg, RESET); - } - } - Ok(CommandResult::Ok) - } - Command::Context => { - context::execute(&self.workspace_ctx); - Ok(CommandResult::Ok) - } - Command::Exit => { - exit::execute(); - self.exit_requested = true; - Ok(CommandResult::Ok) - } - Command::Sessions => { - sessions::execute_list(self.session_id.as_deref()); - Ok(CommandResult::Ok) - } - Command::SessionLoad(id_prefix) => { - if id_prefix.is_empty() { - return Err( - "Usage: /session — use /sessions to list available sessions" - .to_string(), - ); - } - Ok(CommandResult::SwitchSession(id_prefix)) - } - Command::SessionDelete(id_or_name) => { - if id_or_name.is_empty() { - return Err( - "Usage: /session delete — use /sessions to list available sessions" - .to_string(), - ); - } - sessions::execute_delete(&id_or_name, self.session_id.as_deref()); - Ok(CommandResult::Ok) - } - Command::Rename(name) => { - if name.is_empty() { - return Err( - "Usage: /rename — give the current session a descriptive name" - .to_string(), - ); - } - Ok(CommandResult::RenameSession(name)) - } - Command::Settings(arg) => { - settings::execute(arg.as_deref()); - Ok(CommandResult::Ok) - } - Command::ApiKey(arg) => { - if arg.is_empty() { - apikey::execute_show(); - } else if arg == "clear" { - apikey::execute_clear(); - } else { - apikey::execute_set(&arg); - } - Ok(CommandResult::Ok) - } - Command::Compact(focus) => { - let mut provider = self.provider.lock().await; - compact::execute_compact(&mut *provider, messages, &focus).await?; - Ok(CommandResult::Ok) - } - Command::Add(path) => { - if path.is_empty() { - return Err("Usage: /add — e.g. /add src/main.rs".to_string()); - } - files::execute_add(&mut self.file_context, &path); - self.refresh_system_prompt(messages); - Ok(CommandResult::Ok) - } - Command::Drop(path) => { - if path.is_empty() { - return Err("Usage: /drop — e.g. /drop src/main.rs".to_string()); - } - files::execute_drop(&mut self.file_context, &path); - self.refresh_system_prompt(messages); - Ok(CommandResult::Ok) - } - Command::Files => { - files::execute_list(&self.file_context); - Ok(CommandResult::Ok) - } - Command::DropAll => { - files::execute_clear(&mut self.file_context); - self.refresh_system_prompt(messages); - Ok(CommandResult::Ok) - } - Command::Refresh => { - files::execute_refresh(&mut self.file_context); - self.refresh_system_prompt(messages); - Ok(CommandResult::Ok) - } - Command::Init => { - let mut provider = self.provider.lock().await; - let result = - init::execute_init(&mut *provider, &self.workspace_ctx, messages).await?; - // Refresh workspace context since the project instruction file may have changed - self.workspace_ctx = WorkspaceContext::collect(); - self.refresh_system_prompt(messages); - Ok(CommandResult::Init(result)) - } - Command::Timeout(arg) => { - if arg.is_empty() { - let settings = load_settings(); - println!( - "{}Current timeout: {}{}s{}", - BOLD, BLUE, settings.ollama_timeout_secs, RESET - ); - return Ok(CommandResult::Ok); - } - match arg.parse::() { - Ok(secs) if secs > 0 => { - // Update settings - let mut settings = load_settings(); - settings.ollama_timeout_secs = secs; - save_settings(&settings); - // Update live provider - let mut provider = self.provider.lock().await; - provider.set_timeout(secs); - println!( - "{}Timeout set to {}{}s{}.{}", - BOLD, BLUE, secs, RESET, RESET - ); - Ok(CommandResult::Ok) - } - Ok(_) => Err("Timeout must be a positive number of seconds.".to_string()), - Err(_) => Err(format!( - "Invalid timeout value: '{}'. Use a number of seconds, e.g. /timeout 30", - arg - )), - } - } - Command::Retries(arg) => { - if arg.is_empty() { - let settings = load_settings(); - println!( - "{}Current max retries: {}{}{}", - BOLD, BLUE, settings.ollama_max_retries, RESET - ); - return Ok(CommandResult::Ok); - } - match arg.parse::() { - Ok(count) => { - // Update settings - let mut settings = load_settings(); - settings.ollama_max_retries = count; - save_settings(&settings); - // Update live provider - let mut provider = self.provider.lock().await; - provider.set_retries(count); - println!( - "{}Max retries set to {}{}{}.{}", - BOLD, BLUE, count, RESET, RESET - ); - Ok(CommandResult::Ok) - } - Err(_) => Err(format!( - "Invalid retries value: '{}'. Use a number, e.g. /retries 5", - arg - )), - } - } - Command::ContextLimit(arg) => { - if arg.is_empty() { - let settings = load_settings(); - match settings.context_limit { - Some(limit) => { - println!( - "{}Context limit for warnings: {}{} tokens{}", - BOLD, BLUE, limit, RESET - ); - } - None => { - println!( - "{}Context limit: {}auto (using model default){}", - BOLD, GRAY, RESET - ); - } - } - return Ok(CommandResult::Ok); - } - if arg == "auto" || arg == "default" { - // Clear the limit - let mut settings = load_settings(); - settings.context_limit = None; - save_settings(&settings); - println!( - "{}Context limit cleared. Using model default for warnings.{}", - BOLD, RESET - ); - return Ok(CommandResult::Ok); - } - match arg.parse::() { - Ok(limit) if limit > 0 => { - // Update settings - let mut settings = load_settings(); - settings.context_limit = Some(limit); - save_settings(&settings); - println!( - "{}Context limit set to {}{} tokens{} for warning calculations.{}", - BOLD, BLUE, limit, RESET, RESET - ); - Ok(CommandResult::Ok) - } - Ok(_) => Err("Context limit must be a positive number of tokens.".to_string()), - Err(_) => Err(format!( - "Invalid context limit value: '{}'. Use a number of tokens, e.g. /contextlimit 32768, or 'auto' to use model default", - arg - )), + reg.register_sync("/exit", "Exit the application", |_arg, ctx, _msg| { + crate::commands::exit::execute(); + ctx.exit_requested = true; + Ok(CommandResult::Ok) + }); + + reg.register_sync( + "/context", + "Show the workspace context available to the agent", + |_arg, ctx, _msg| { + crate::commands::context::execute(&ctx.workspace_ctx); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync_with_usage( + "/rename", + "Rename the current session", + "/rename ", + |arg, _ctx, _msg| crate::commands::rename::execute(arg.unwrap_or("")), + ); + + reg.register_sync("/help", "Show this help message", |_arg, ctx, _msg| { + crate::commands::help::execute(&ctx.command_descriptions); + Ok(CommandResult::Ok) + }); + + // ── Mode ────────────────────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/mode", + "Show or switch mode (casual/planning/agent/research)", + "/mode [mode]", + crate::commands::mode::execute, + ); + + // ── Settings ─────────────────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/settings", + "Show current settings. Use 'all' to list all safe commands.", + "/settings [all]", + |arg, _ctx, _msg| crate::commands::settings::execute(arg), + ); + + reg.register_sync_with_usage( + "/apikey", + "Set or show the Ollama API key for web search. Use /apikey clear to remove it.", + "/apikey [key]", + |arg, _ctx, _msg| { + let a = arg.unwrap_or(""); + if a.is_empty() { + crate::commands::apikey::execute_show(); + } else if a == "clear" { + crate::commands::apikey::execute_clear(); + } else { + crate::commands::apikey::execute_set(a); + } + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync_with_usage( + "/contextlimit", + "Show or set the context limit for warning calculations (default: model default)", + "/contextlimit [tokens]", + |arg, _ctx, _msg| crate::commands::config_settings::execute_context_limit(arg), + ); + + reg.register_sync_with_usage( + "/autoaccept", + "Show or toggle auto-accept for safe read-only commands (default: on)", + "/autoaccept [on|off]", + |arg, _ctx, _msg| crate::commands::config_settings::execute_autoaccept(arg), + ); + + // ── Command management ──────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/command", + "Manage auto-accepted and denied commands", + "/command [list|add|rm|deny|undeny|reset|resetdeny|help]", + |arg, _ctx, _msg| crate::commands::command::execute(arg.unwrap_or("")), + ); + + // ── Audit ────────────────────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/audit", + "View command execution audit log", + "/audit [last|session|clear]", + |arg, _ctx, _msg| { + crate::commands::audit::execute(arg.unwrap_or("")); + Ok(CommandResult::Ok) + }, + ); + + // ── Sessions ────────────────────────────────────────────────────────── + + reg.register_sync("/sessions", "List all saved sessions", |_arg, ctx, _msg| { + crate::commands::sessions::execute_list(ctx.session_id.as_deref()); + Ok(CommandResult::Ok) + }); + + reg.register_sync_with_usage("/session", "Switch to an existing session (accepts ID prefix)", "/session ", + |arg, ctx, _msg| { + let a = arg.unwrap_or("").to_string(); + if a.is_empty() { + return Err("Usage: /session — use /sessions to list available sessions".to_string()); + } + if a.starts_with("delete ") || a == "delete" { + let id = a.strip_prefix("delete ").unwrap_or("").trim().to_string(); + if id.is_empty() { + return Err("Usage: /session delete — use /sessions to list available sessions".to_string()); } + crate::commands::sessions::execute_delete(&id, ctx.session_id.as_deref()); + return Ok(CommandResult::Ok); } - Command::AutoAccept(arg) => { - if arg.is_empty() { - let settings = load_settings(); - let status = if settings.auto_accept_safe_commands { - "enabled" - } else { - "disabled" - }; - let color = if settings.auto_accept_safe_commands { - GREEN - } else { - ORANGE - }; - println!( - "{}Auto-accept safe commands: {}{}{}{}", - BOLD, color, status, RESET, RESET - ); + Ok(CommandResult::SwitchSession(a)) + }); + + // ── File pinning ────────────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/add", + "Pin a file into the AI's context so it's always available", + "/add ", + |arg, ctx, msg| { + let path = arg.unwrap_or("").to_string(); + if path.is_empty() { + return Err("Usage: /add — e.g. /add src/main.rs".to_string()); + } + crate::commands::files::execute_add(&mut ctx.file_context, &path); + ctx.refresh_system_prompt(msg); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync_with_usage( + "/drop", + "Remove a pinned file from context", + "/drop ", + |arg, ctx, msg| { + let path = arg.unwrap_or("").to_string(); + if path.is_empty() { + return Err("Usage: /drop — e.g. /drop src/main.rs".to_string()); + } + crate::commands::files::execute_drop(&mut ctx.file_context, &path); + ctx.refresh_system_prompt(msg); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync( + "/files", + "List all pinned files in context", + |_arg, ctx, _msg| { + crate::commands::files::execute_list(&ctx.file_context); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync( + "/dropall", + "Remove all pinned files from context", + |_arg, ctx, msg| { + crate::commands::files::execute_clear(&mut ctx.file_context); + ctx.refresh_system_prompt(msg); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync( + "/refresh", + "Re-read all pinned files from disk (updates content)", + |_arg, ctx, msg| { + crate::commands::files::execute_refresh(&mut ctx.file_context); + ctx.refresh_system_prompt(msg); + Ok(CommandResult::Ok) + }, + ); + + // ── Skills ──────────────────────────────────────────────────────────── + + reg.register_sync("/skills", "List all available skills", |_arg, ctx, _msg| { + crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); + Ok(CommandResult::Ok) + }); + + reg.register_sync_with_usage( + "/skill", + "Show details and content of a skill", + "/skill ", + |arg, ctx, _msg| { + let name = arg.unwrap_or("").to_string(); + if name.is_empty() { + crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); + return Ok(CommandResult::Ok); + } + // Check for "use " subcommand + if let Some(skill_name) = name.strip_prefix("use ") { + let skill_name = skill_name.trim().to_string(); + if skill_name.is_empty() { + crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); return Ok(CommandResult::Ok); } - let new_value = match arg.to_lowercase().as_str() { - "on" | "true" | "yes" | "1" => true, - "off" | "false" | "no" | "0" => false, - _ => { - return Err( - "Invalid value. Use 'on' or 'off', e.g. /autoaccept on".to_string() - ); - } - }; - // Update settings - let mut settings = load_settings(); - settings.auto_accept_safe_commands = new_value; - save_settings(&settings); - let status = if new_value { "enabled" } else { "disabled" }; - let color = if new_value { GREEN } else { ORANGE }; + return crate::commands::skill::handle_skill_use(&skill_name, ctx); + } + let mut stdout = std::io::stdout(); + crate::commands::skill::execute_show( + &ctx.skill_registry, + &name, + &ctx.active_skills, + &mut stdout, + ); + Ok(CommandResult::Ok) + }, + ); + + reg.register_sync_with_usage( + "/use", + "Activate a skill, injecting its instructions into the conversation", + "/use ", + |arg, ctx, _msg| { + let name = arg.unwrap_or("").to_string(); + if name.is_empty() { + crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); + return Ok(CommandResult::Ok); + } + crate::commands::skill::handle_skill_use(&name, ctx) + }, + ); + + reg.register_sync_with_usage( + "/unload", + "Deactivate a previously loaded skill", + "/unload ", + |arg, ctx, _msg| { + let name = arg.unwrap_or("").to_string(); + if name.is_empty() { + crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); + return Ok(CommandResult::Ok); + } + if ctx + .active_skills + .iter() + .any(|s| s.eq_ignore_ascii_case(&name)) + { + Ok(CommandResult::SkillUnload(name)) + } else { println!( - "{}Auto-accept safe commands set to {}{}{}{}", - BOLD, color, status, RESET, RESET - ); - Ok(CommandResult::Ok) - } - Command::CommandList => { - command::execute_list(); - Ok(CommandResult::Ok) - } - Command::CommandReset => { - command::execute_reset(); - Ok(CommandResult::Ok) - } - Command::CommandResetDeny => { - command::execute_reset_deny(); - Ok(CommandResult::Ok) - } - Command::CommandHelp => { - command::execute_help(); - Ok(CommandResult::Ok) - } - Command::CommandAdd(cmd) => { - command::execute_add(&cmd); - Ok(CommandResult::Ok) - } - Command::CommandRemove(cmd) => { - command::execute_remove(&cmd); - Ok(CommandResult::Ok) - } - Command::CommandDeny(cmd) => { - command::execute_deny(&cmd); - Ok(CommandResult::Ok) - } - Command::CommandUndeny(cmd) => { - command::execute_undeny(&cmd); - Ok(CommandResult::Ok) - } - Command::Audit(args) => { - audit::execute(&args); - Ok(CommandResult::Ok) - } - Command::SkillList => { - skill::execute_list(&self.skill_registry, &self.active_skills); - Ok(CommandResult::Ok) - } - Command::SkillShow(name) => { - let mut stdout = std::io::stdout(); - skill::execute_show( - &self.skill_registry, - &name, - &self.active_skills, - &mut stdout, + "{}Skill '{}' is not currently active.{}", + crate::style::ORANGE, + name, + crate::style::RESET ); - Ok(CommandResult::Ok) - } - Command::SkillUse(name) => { - // Validate that the skill exists and is user-invocable - match self.skill_registry.get(&name) { - Some(skill) if !skill.user_invocable => { - println!( - "{}Skill '{}' is not user-invocable.{} It can only be activated by the model.", - ORANGE, name, RESET - ); - Ok(CommandResult::Ok) - } - Some(_) => Ok(CommandResult::SkillUse(name)), - None => { - let available = self - .skill_registry - .skills - .iter() - .map(|s| s.name.clone()) - .collect::>() - .join(", "); - println!( - "{}Skill '{}' not found.{} Use {}/skills{} to list available skills.", - RED, name, RESET, BOLD, RESET - ); - if !available.is_empty() { - println!("{}Available skills: {}{}{}", GRAY, CYAN, available, RESET); - } - Ok(CommandResult::Ok) - } - } - } - Command::SkillUnload(name) => { - if self - .active_skills - .iter() - .any(|s| s.eq_ignore_ascii_case(&name)) - { - Ok(CommandResult::SkillUnload(name)) - } else { + if !ctx.active_skills.is_empty() { + let active = ctx.active_skills.join(", "); println!( - "{}Skill '{}' is not currently active.{}", - ORANGE, name, RESET + "{}Active skills: {}{}{}", + crate::style::GRAY, + crate::style::CYAN, + active, + crate::style::RESET ); - if !self.active_skills.is_empty() { - let active = self.active_skills.join(", "); - println!("{}Active skills: {}{}{}", GRAY, CYAN, active, RESET); - } - Ok(CommandResult::Ok) } + Ok(CommandResult::Ok) } - } - } + }, + ); + + // ── Async commands (need provider.lock().await) ──────────────────────── + + reg.register(ModelsCommand); + reg.register(ModelCommand); + reg.register(CompactCommand); + reg.register(InitCommand); + reg.register(TimeoutCommand); + reg.register(RetriesCommand); + + // ── Aliases ─────────────────────────────────────────────────────────── + + // Mode aliases: /plan, /agent, /research, /casual → /mode + reg.register_alias( + "/plan", + "/mode", + Some("planning"), + "Switch to planning mode (alias for /mode planning)", + ); + reg.register_alias( + "/agent", + "/mode", + Some("agent"), + "Switch to agent mode (alias for /mode agent)", + ); + reg.register_alias( + "/research", + "/mode", + Some("research"), + "Switch to research mode (alias for /mode research)", + ); + reg.register_alias( + "/casual", + "/mode", + Some("casual"), + "Switch to casual mode (alias for /mode casual)", + ); + + // Exit alias: /quit → /exit + reg.register_alias("/quit", "/exit", None, "Exit the application"); + + reg +} + +/// Create a new CommandContext with the given provider and workspace context. +pub fn create_context( + provider: Arc>, + workspace_ctx: WorkspaceContext, +) -> CommandContext { + CommandContext::new(provider, workspace_ctx) } diff --git a/src/commands/mode.rs b/src/commands/mode.rs new file mode 100644 index 0000000..d232ccd --- /dev/null +++ b/src/commands/mode.rs @@ -0,0 +1,36 @@ +use tinyharness_lib::mode::AgentMode; +use tinyharness_lib::provider::Message; + +use crate::commands::registry::{CommandContext, CommandResult}; +use crate::style::*; + +/// Execute the /mode command. +#[allow(clippy::ptr_arg)] +pub fn execute( + arg: Option<&str>, + ctx: &mut CommandContext, + messages: &mut Vec, +) -> Result { + let mode_str = arg.unwrap_or(""); + + if mode_str.is_empty() { + println!( + "{}Current mode: {}{}{}", + BOLD, BLUE, ctx.current_mode, RESET + ); + return Ok(CommandResult::Ok); + } + + let new_mode: AgentMode = mode_str.parse()?; + + match ctx.switch_mode(new_mode, messages) { + Ok(()) => { + println!("{}Switched to {} mode.{}", BOLD, BLUE, RESET); + } + Err(msg) => { + println!("{}{}{}", ORANGE, msg, RESET); + } + } + + Ok(CommandResult::Ok) +} diff --git a/src/commands/models.rs b/src/commands/models.rs index b5ef18b..c4c5cd4 100644 --- a/src/commands/models.rs +++ b/src/commands/models.rs @@ -1,7 +1,90 @@ -use tinyharness_lib::provider::Provider; +use tinyharness_lib::config::{load_settings, save_settings}; +use tinyharness_lib::provider::{Message, Provider}; +use crate::commands::registry::{Command, CommandContext, CommandResult}; use crate::style::*; +use std::future::Future; +use std::pin::Pin; + +pub struct ModelsCommand; + +impl Command for ModelsCommand { + fn name(&self) -> &'static str { + "/models" + } + + fn description(&self) -> &'static str { + "List available models" + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + _messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + let name = raw_arg.unwrap_or("").to_string(); + let provider = ctx.provider.clone(); + + Box::pin(async move { + if name.is_empty() { + // No argument — list available models and show current + let p = provider.lock().await; + execute_list(&*p).await?; + + // Show current selection + if let Some(model) = p.current_model() { + println!( + "{}Current model: {}{}{}{}", + BOLD, GREEN, model, RESET, RESET + ); + } else { + println!("{}No model currently selected.{}", ORANGE, RESET); + } + return Ok(CommandResult::Ok); + } + + let mut p = provider.lock().await; + execute_select(&mut *p, &name).await?; + + // Auto-save model + let mut settings = load_settings(); + settings.last_model = p.current_model(); + save_settings(&settings); + + Ok(CommandResult::Ok) + }) + } +} + +/// Also handle "/model " as a separate command that delegates +pub struct ModelCommand; + +impl Command for ModelCommand { + fn name(&self) -> &'static str { + "/model" + } + + fn description(&self) -> &'static str { + "Switch to a different model" + } + + fn usage(&self) -> &'static str { + "/model " + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + // Delegate to ModelsCommand with the arg + ModelsCommand.execute(raw_arg, ctx, messages) + } +} + pub async fn execute_list(provider: &dyn Provider) -> Result<(), String> { let models = provider.list_models().await; if models.is_empty() { diff --git a/src/commands/registry.rs b/src/commands/registry.rs new file mode 100644 index 0000000..9deccc3 --- /dev/null +++ b/src/commands/registry.rs @@ -0,0 +1,466 @@ +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; + +use tokio::sync::Mutex; + +use tinyharness_lib::{ + config::{load_settings, save_settings}, + context::WorkspaceContext, + mode::AgentMode, + provider::{Message, Provider, Role}, + skill::SkillRegistry, +}; + +use crate::commands::files::FileContext; +use crate::commands::init::InitResult; + +// ── CommandResult ──────────────────────────────────────────────────────────── + +/// Result of dispatching a command. +pub enum CommandResult { + /// Command completed normally. + Ok, + /// The user wants to switch to a different session. + SwitchSession(String), + /// The user wants to rename the current session. + RenameSession(String), + /// The /init command was run — workspace context should be refreshed. + Init(InitResult), + /// The user wants to activate a skill, injecting its instructions into the conversation. + SkillUse(String), + /// The user wants to deactivate (unload) a skill. + SkillUnload(String), +} + +// ── CommandContext ──────────────────────────────────────────────────────────── + +/// Context passed to every command handler. +/// Holds the shared state that commands may need to read or mutate. +pub struct CommandContext { + pub provider: Arc>, + pub exit_requested: bool, + pub current_mode: AgentMode, + pub workspace_ctx: WorkspaceContext, + pub file_context: FileContext, + pub session_id: Option, + pub skill_registry: SkillRegistry, + /// Names of currently active (loaded) skills. + pub active_skills: Vec, + /// Cached command descriptions from the registry (for /help). + pub command_descriptions: Vec<(&'static str, &'static str)>, +} + +impl CommandContext { + pub fn new( + provider: Arc>, + workspace_ctx: WorkspaceContext, + ) -> Self { + CommandContext { + provider, + exit_requested: false, + current_mode: AgentMode::Casual, + workspace_ctx, + file_context: FileContext::new(), + session_id: None, + skill_registry: SkillRegistry::discover(), + active_skills: Vec::new(), + command_descriptions: Vec::new(), + } + } + + /// Update the system prompt message in the conversation to reflect the current + /// mode, workspace context, and pinned files. Call this after any change that + /// affects the system prompt content (mode switch, add/drop/refresh files, etc.). + pub fn refresh_system_prompt(&self, messages: &mut [Message]) { + if let Some(sys_msg) = messages.iter_mut().find(|m| m.role == Role::System) { + sys_msg.content = self.build_system_prompt(); + } + } + + /// Switch the current mode to `new_mode`. Updates the system prompt in the + /// conversation and auto-saves the new mode to settings. + /// Returns `Ok(())` on success or an error string if the mode is unchanged/invalid. + pub fn switch_mode( + &mut self, + new_mode: AgentMode, + messages: &mut [Message], + ) -> Result<(), String> { + if new_mode == self.current_mode { + return Err(format!("Already in '{}' mode", new_mode)); + } + + self.current_mode = new_mode; + self.refresh_system_prompt(messages); + + // Auto-save mode + let mut settings = load_settings(); + settings.preferred_mode = self.current_mode; + save_settings(&settings); + + Ok(()) + } + + /// Build the system prompt for the current mode, appending workspace context, + /// pinned file content, skill index, and active skill instructions. + pub fn build_system_prompt(&self) -> String { + let mut prompt = format!( + "{}\n\n---\n{}", + self.current_mode.system_prompt(), + self.workspace_ctx.format() + ); + + // Inject pinned file content + if !self.file_context.is_empty() { + prompt.push_str(&self.file_context.format_for_prompt()); + } + + // Inject skill index for model auto-invocation + let skill_index = self.skill_registry.format_index_for_prompt(); + if !skill_index.is_empty() { + prompt.push_str("\n\n"); + prompt.push_str(&skill_index); + } + + // Inject active skill instructions + for name in &self.active_skills { + if let Some(skill) = self.skill_registry.get(name) { + prompt.push_str("\n\n"); + prompt.push_str(&self.skill_registry.format_skill_content(skill)); + } + } + + prompt + } +} + +// ── Command trait ──────────────────────────────────────────────────────────── + +/// A self-contained command definition. +/// +/// Each command implements this trait to provide its name, aliases, description, +/// usage string, and execution logic. The registry dispatches user input to the +/// appropriate `Command` implementation. +pub trait Command: Send + Sync { + /// Primary name (e.g., "/help"). + fn name(&self) -> &'static str; + + /// Aliases that also invoke this command (e.g., `["/quit"]` for exit). + fn aliases(&self) -> &'static [&'static str] { + &[] + } + + /// One-line description for /help. + fn description(&self) -> &'static str; + + /// Usage string (e.g., "/model "). Defaults to the command name. + fn usage(&self) -> &'static str { + self.name() + } + + /// Parse and execute the command. + /// + /// `raw_arg` is the argument portion of the input (after the command name), + /// or `None` if no argument was provided. + /// + /// Returns a `CommandResult` on success, or an error string on failure. + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + messages: &'a mut Vec, + ) -> Pin> + Send + 'a>>; +} + +// ── SyncCommand ────────────────────────────────────────────────────────────── + +/// A synchronous command that wraps a simple function. +/// +/// This eliminates the need for the full `Command` trait boilerplate when a +/// command's execute logic is purely synchronous (no `.await`, no provider lock). +/// The closure receives `(arg, ctx, messages)` and returns `Result`. +pub struct SyncCommand +where + F: Fn(Option<&str>, &mut CommandContext, &mut Vec) -> Result + + Send + + Sync, +{ + pub name_str: &'static str, + pub description_str: &'static str, + pub usage_str: &'static str, + pub aliases_str: &'static [&'static str], + pub handler: F, +} + +impl Command for SyncCommand +where + F: Fn(Option<&str>, &mut CommandContext, &mut Vec) -> Result + + Send + + Sync, +{ + fn name(&self) -> &'static str { + self.name_str + } + + fn aliases(&self) -> &'static [&'static str] { + self.aliases_str + } + + fn description(&self) -> &'static str { + self.description_str + } + + fn usage(&self) -> &'static str { + self.usage_str + } + + fn execute<'a>( + &'a self, + raw_arg: Option<&str>, + ctx: &'a mut CommandContext, + messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + // Call the sync handler and wrap the result in an immediately-resolved future + match (self.handler)(raw_arg, ctx, messages) { + Ok(result) => Box::pin(async move { Ok(result) }), + Err(e) => Box::pin(async move { Err(e) }), + } + } +} + +// ── AliasCommand ───────────────────────────────────────────────────────────── + +/// A lightweight alias that delegates to another command with an optional fixed argument. +/// +/// For example, `/plan` is an alias for `/mode` with the fixed arg `"planning"`. +pub struct AliasCommand { + pub alias_name: &'static str, + pub target_name: &'static str, + /// If set, this arg is passed to the target instead of the user's arg. + pub fixed_arg: Option<&'static str>, + pub description: &'static str, +} + +impl Command for AliasCommand { + fn name(&self) -> &'static str { + self.alias_name + } + + fn aliases(&self) -> &'static [&'static str] { + &[] + } + + fn description(&self) -> &'static str { + self.description + } + + fn usage(&self) -> &'static str { + self.alias_name + } + + fn execute<'a>( + &self, + raw_arg: Option<&str>, + _ctx: &'a mut CommandContext, + _messages: &'a mut Vec, + ) -> Pin> + Send + 'a>> { + // Aliases are resolved by the registry's dispatch method. + // This method should never be called directly. + let _ = raw_arg; + Box::pin(async { Ok(CommandResult::Ok) }) + } +} + +// ── CommandRegistry ────────────────────────────────────────────────────────── + +/// The command registry — maps command names and aliases to their handler implementations. +pub struct CommandRegistry { + /// Primary command handlers, keyed by primary name. + commands: HashMap<&'static str, Box>, + /// Alias → target command name mapping. + aliases: HashMap<&'static str, &'static str>, + /// Fixed args for aliases (alias → arg to pass instead of user input). + alias_fixed_args: HashMap<&'static str, &'static str>, +} + +impl Default for CommandRegistry { + fn default() -> Self { + Self::new() + } +} + +impl CommandRegistry { + pub fn new() -> Self { + CommandRegistry { + commands: HashMap::new(), + aliases: HashMap::new(), + alias_fixed_args: HashMap::new(), + } + } + + /// Register a command implementation. + /// Also registers any aliases the command declares. + pub fn register(&mut self, cmd: impl Command + 'static) { + let name = cmd.name(); + let aliases = cmd.aliases(); + self.commands.insert(name, Box::new(cmd)); + + // Register aliases that point to the primary name + for alias in aliases { + self.aliases.insert(alias, name); + } + } + + /// Register an alias for an existing command. + /// + /// If `fixed_arg` is provided, that string is passed as the argument + /// instead of whatever the user typed. + pub fn register_alias( + &mut self, + alias: &'static str, + target: &'static str, + fixed_arg: Option<&'static str>, + description: &'static str, + ) { + self.aliases.insert(alias, target); + if let Some(arg) = fixed_arg { + self.alias_fixed_args.insert(alias, arg); + } + // Store the description so it appears in /help + let _ = description; // Used by command_descriptions() + } + + /// Register a synchronous command using a closure. + /// + /// This is a convenience method that creates a `SyncCommand` internally, + /// avoiding the need for a full `impl Command` block. + /// + /// # Example + /// ```ignore + /// reg.register_sync("/clear", "Clear the terminal screen", + /// |_arg, _ctx, _msg| { print!("{}", CLEAR_SCREEN); Ok(CommandResult::Ok) }); + /// ``` + pub fn register_sync(&mut self, name: &'static str, description: &'static str, handler: F) + where + F: Fn( + Option<&str>, + &mut CommandContext, + &mut Vec, + ) -> Result + + Send + + Sync + + 'static, + { + let cmd = SyncCommand { + name_str: name, + description_str: description, + usage_str: name, + aliases_str: &[], + handler, + }; + self.register(cmd); + } + + /// Register a synchronous command with a custom usage string. + pub fn register_sync_with_usage( + &mut self, + name: &'static str, + description: &'static str, + usage: &'static str, + handler: F, + ) where + F: Fn( + Option<&str>, + &mut CommandContext, + &mut Vec, + ) -> Result + + Send + + Sync + + 'static, + { + let cmd = SyncCommand { + name_str: name, + description_str: description, + usage_str: usage, + aliases_str: &[], + handler, + }; + self.register(cmd); + } + + /// Parse user input and dispatch to the appropriate command handler. + /// + /// Returns `Err(msg)` if the command is unknown or the handler fails. + pub async fn dispatch( + &self, + input: &str, + ctx: &mut CommandContext, + messages: &mut Vec, + ) -> Result { + let input = input.trim(); + if !input.starts_with('/') { + return Err("Input is not a command".to_string()); + } + + let parts: Vec<&str> = input.splitn(2, ' ').collect(); + let cmd_name = parts[0].to_lowercase(); + let raw_arg = parts.get(1).map(|s| s.trim()).filter(|s| !s.is_empty()); + + // Resolve aliases + let (resolved_name, effective_arg) = + if let Some(&target) = self.aliases.get(cmd_name.as_str()) { + let arg = self + .alias_fixed_args + .get(cmd_name.as_str()) + .copied() + .or(raw_arg); + (target, arg) + } else { + (cmd_name.as_str(), raw_arg) + }; + + let handler = self + .commands + .get(resolved_name) + .ok_or_else(|| format!("Unknown command: {}", cmd_name))?; + + // Populate command descriptions for /help + ctx.command_descriptions = self.command_descriptions(); + + handler.execute(effective_arg, ctx, messages).await + } + + /// Get all command names (primary + aliases), sorted for display. + pub fn command_names(&self) -> Vec<&'static str> { + let mut names: Vec<&'static str> = self.commands.keys().copied().collect(); + names.extend(self.aliases.keys().copied()); + names.sort(); + names + } + + /// Get (usage, description) pairs for /help display. + /// + /// Returns entries from registered commands plus alias entries. + pub fn command_descriptions(&self) -> Vec<(&'static str, &'static str)> { + let mut descs: Vec<(&'static str, &'static str)> = self + .commands + .values() + .map(|cmd| (cmd.usage(), cmd.description())) + .collect(); + + // Add alias entries + for (&alias, &target) in &self.aliases { + if let Some(cmd) = self.commands.get(target) { + let desc = cmd.description(); + // Check if this alias has a custom description stored + descs.push((alias, desc)); + } + } + + descs.sort_by(|a, b| a.0.cmp(b.0)); + descs + } + + /// Check if a command name (or alias) is registered. + pub fn contains(&self, name: &str) -> bool { + self.commands.contains_key(name) || self.aliases.contains_key(name) + } +} diff --git a/src/commands/rename.rs b/src/commands/rename.rs new file mode 100644 index 0000000..d8ba5f5 --- /dev/null +++ b/src/commands/rename.rs @@ -0,0 +1,11 @@ +use crate::commands::registry::CommandResult; + +pub fn execute(name: &str) -> Result { + if name.is_empty() { + return Err( + "Usage: /rename — give the current session a descriptive name".to_string(), + ); + } + + Ok(CommandResult::RenameSession(name.to_string())) +} diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 7caa146..9370b48 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -1,6 +1,7 @@ use tinyharness_lib::session::{SessionMeta, SessionStore, format_age}; use crate::style::*; + use std::io::{self, Write}; /// Format a session for display in the `/sessions` listing. diff --git a/src/commands/settings.rs b/src/commands/settings.rs index b1f23ae..14e842d 100644 --- a/src/commands/settings.rs +++ b/src/commands/settings.rs @@ -2,21 +2,21 @@ use std::collections::HashSet; use tinyharness_lib::config::load_settings; +use crate::commands::registry::CommandResult; use crate::style::*; -pub fn execute(arg: Option<&str>) { - let settings = load_settings(); - +pub fn execute(arg: Option<&str>) -> Result { match arg { - Some(sub) if sub.to_lowercase() == "all" => execute_all(&settings), + Some(sub) if sub.to_lowercase() == "all" => execute_all(&load_settings()), Some(other) => { println!( "{}Unknown argument '{}'.{} Use {}/settings{} to show settings or {}/settings all{} to list all safe commands.", ORANGE, other, RESET, BOLD, RESET, BOLD, RESET ); } - None => execute_summary(&settings), + None => execute_summary(&load_settings()), } + Ok(CommandResult::Ok) } fn execute_summary(settings: &tinyharness_lib::config::Settings) { diff --git a/src/commands/skill.rs b/src/commands/skill.rs index ab7c859..a70064c 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -2,8 +2,47 @@ use std::io::Write; use tinyharness_lib::skill::{SkillRegistry, SkillSource}; +use crate::commands::registry::{CommandContext, CommandResult}; use crate::style::*; +// ── Helper for /use and /skill use ────────────────────────────────────────── + +pub fn handle_skill_use(name: &str, ctx: &mut CommandContext) -> Result { + // Validate that the skill exists and is user-invocable + match ctx.skill_registry.get(name) { + Some(skill) if !skill.user_invocable => { + println!( + "{}Skill '{}' is not user-invocable.{} It can only be activated by the model.", + ORANGE, name, RESET + ); + Ok(CommandResult::Ok) + } + Some(_) => { + let name = name.to_string(); + Ok(CommandResult::SkillUse(name)) + } + None => { + let available = ctx + .skill_registry + .skills + .iter() + .map(|s| s.name.clone()) + .collect::>() + .join(", "); + println!( + "{}Skill '{}' not found.{} Use {}/skills{} to list available skills.", + RED, name, RESET, BOLD, RESET + ); + if !available.is_empty() { + println!("{}Available skills: {}{}{}", GRAY, CYAN, available, RESET); + } + Ok(CommandResult::Ok) + } + } +} + +// ── Display functions ──────────────────────────────────────────────────────── + /// List all available skills, marking active ones. pub fn execute_list(registry: &SkillRegistry, active_skills: &[String]) { if registry.skills.is_empty() { diff --git a/src/main.rs b/src/main.rs index 59372d2..b1a51cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use tinyharness_lib::{ tools::ToolManager, }; -use crate::{agent::run_agent_loop, commands::CommandDispatcher}; +use crate::{agent::run_agent_loop, commands::CommandContext}; use clap::Parser; use style::*; use tokio::sync::Mutex; @@ -280,15 +280,15 @@ async fn main() -> Result<(), Box> { ) }); - let mut dispatcher = CommandDispatcher::new(Arc::clone(&provider), workspace_ctx); - dispatcher.current_mode = initial_mode; - dispatcher.session_id = Some(session.id().to_string()); + let mut ctx = CommandContext::new(Arc::clone(&provider), workspace_ctx); + ctx.current_mode = initial_mode; + ctx.session_id = Some(session.id().to_string()); run_agent_loop( provider, tool_manager, &mut messages, - &mut dispatcher, + &mut ctx, &mut session, &interrupted, ) diff --git a/src/ui/input.rs b/src/ui/input.rs index ee1def2..5aee3b5 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -6,9 +6,47 @@ use rustyline::{ validate::{ValidationContext, ValidationResult, Validator}, }; -use crate::commands::CommandDispatcher; use crate::style::*; +/// All known command names (primary + aliases), used for completion and hints. +/// This must be kept in sync with the command registry in `commands/mod.rs`. +const COMMAND_NAMES: &[&str] = &[ + "/add", + "/agent", + "/apikey", + "/audit", + "/autoaccept", + "/casual", + "/clear", + "/command", + "/compact", + "/context", + "/contextlimit", + "/drop", + "/dropall", + "/exit", + "/files", + "/help", + "/init", + "/mode", + "/model", + "/models", + "/plan", + "/quit", + "/refresh", + "/rename", + "/retries", + "/research", + "/session", + "/sessions", + "/settings", + "/skill", + "/skills", + "/timeout", + "/unload", + "/use", +]; + /// Subcommand completions for commands that take arguments. fn subcommand_completions(cmd: &str) -> Vec<&'static str> { match cmd { @@ -129,7 +167,7 @@ impl Completer for CommandCompleter { // Top-level command completion let cmd_prefix = prefix.to_lowercase(); - let matches: Vec = CommandDispatcher::command_names() + let matches: Vec = COMMAND_NAMES .iter() .filter(|name| name.starts_with(&cmd_prefix)) .take(3) @@ -189,7 +227,7 @@ impl Hinter for CommandHinter { // Top-level command hinting let prefix = line.to_lowercase(); - let matches: Vec<&str> = CommandDispatcher::command_names() + let matches: Vec<&str> = COMMAND_NAMES .iter() .filter(|name| name.starts_with(&prefix)) .take(3) From 69561db2f37c46840932e9581d0822485349275c Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Sun, 17 May 2026 13:24:10 +0200 Subject: [PATCH 2/4] refactor: convert remaining async commands to async_command! macro - Convert CompactCommand, TimeoutCommand, RetriesCommand, ModelsCommand, InitCommand, and ConfigSettingsCommand to use the async_command! macro - Eliminates ~25 lines of boilerplate per async command - Net -105 lines across 6 files (144 additions, 249 deletions) - All 87 tests passing, clean clippy --- src/commands/compact.rs | 40 +++------ src/commands/config_settings.rs | 90 ++++++------------- src/commands/init.rs | 35 +++----- src/commands/mod.rs | 11 ++- src/commands/models.rs | 69 +++------------ src/commands/registry.rs | 148 +++++++++++++++++--------------- 6 files changed, 144 insertions(+), 249 deletions(-) diff --git a/src/commands/compact.rs b/src/commands/compact.rs index 18f18be..969a2ec 100644 --- a/src/commands/compact.rs +++ b/src/commands/compact.rs @@ -4,45 +4,27 @@ use tinyharness_lib::{ token::{estimate_conversation_tokens, estimate_tokens}, }; -use crate::commands::registry::{Command, CommandContext, CommandResult}; +use crate::async_command; +use crate::commands::registry::CommandResult; use crate::style::*; -use std::future::Future; -use std::pin::Pin; - // ── Command trait implementation ──────────────────────────────────────────── -pub struct CompactCommand; - -impl Command for CompactCommand { - fn name(&self) -> &'static str { - "/compact" - } - - fn description(&self) -> &'static str { - "Summarize conversation history to free context space. Optionally specify a focus area." - } - - fn usage(&self) -> &'static str { - "/compact [focus]" - } - - fn execute<'a>( - &'a self, - raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { +async_command!( + CompactCommand, + "/compact", + "Summarize conversation history to free context space. Optionally specify a focus area.", + "/compact [focus]", + |raw_arg, ctx, messages| { let focus = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); - - Box::pin(async move { + async move { let mut p = provider.lock().await; execute_compact(&mut *p, messages, &focus).await?; Ok(CommandResult::Ok) - }) + } } -} +); // ── Core implementation ───────────────────────────────────────────────────── diff --git a/src/commands/config_settings.rs b/src/commands/config_settings.rs index e244e3f..4630482 100644 --- a/src/commands/config_settings.rs +++ b/src/commands/config_settings.rs @@ -1,39 +1,20 @@ use tinyharness_lib::config::{load_settings, save_settings}; -use tinyharness_lib::provider::Message; -use crate::commands::registry::{Command, CommandContext, CommandResult}; +use crate::async_command; +use crate::commands::registry::CommandResult; use crate::style::*; -use std::future::Future; -use std::pin::Pin; +// ── Timeout (async — needs provider.lock().await) ───────────────────────────── -// ── Timeout ────────────────────────────────────────────────────────────────── - -pub struct TimeoutCommand; - -impl Command for TimeoutCommand { - fn name(&self) -> &'static str { - "/timeout" - } - - fn description(&self) -> &'static str { - "Show or set the Ollama request timeout in seconds (default: 5)" - } - - fn usage(&self) -> &'static str { - "/timeout [secs]" - } - - fn execute<'a>( - &'a self, - raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - _messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { +async_command!( + TimeoutCommand, + "/timeout", + "Show or set the Ollama request timeout in seconds (default: 5)", + "/timeout [secs]", + |raw_arg, ctx, _messages| { let arg = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); - - Box::pin(async move { + async move { if arg.is_empty() { let settings = load_settings(); println!( @@ -45,11 +26,9 @@ impl Command for TimeoutCommand { match arg.parse::() { Ok(secs) if secs > 0 => { - // Update settings let mut settings = load_settings(); settings.ollama_timeout_secs = secs; save_settings(&settings); - // Update live provider let mut p = provider.lock().await; p.set_timeout(secs); println!( @@ -64,37 +43,21 @@ impl Command for TimeoutCommand { arg )), } - }) - } -} - -// ── Retries ────────────────────────────────────────────────────────────────── - -pub struct RetriesCommand; - -impl Command for RetriesCommand { - fn name(&self) -> &'static str { - "/retries" - } - - fn description(&self) -> &'static str { - "Show or set the maximum number of Ollama request retries (default: 3)" + } } +); - fn usage(&self) -> &'static str { - "/retries [count]" - } +// ── Retries (async — needs provider.lock().await) ───────────────────────────── - fn execute<'a>( - &'a self, - raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - _messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { +async_command!( + RetriesCommand, + "/retries", + "Show or set the maximum number of Ollama request retries (default: 3)", + "/retries [count]", + |raw_arg, ctx, _messages| { let arg = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); - - Box::pin(async move { + async move { if arg.is_empty() { let settings = load_settings(); println!( @@ -106,11 +69,9 @@ impl Command for RetriesCommand { match arg.parse::() { Ok(count) => { - // Update settings let mut settings = load_settings(); settings.ollama_max_retries = count; save_settings(&settings); - // Update live provider let mut p = provider.lock().await; p.set_retries(count); println!( @@ -124,11 +85,11 @@ impl Command for RetriesCommand { arg )), } - }) + } } -} +); -// ── ContextLimit (sync) ───────────────────────────────────────────────────── +// ── ContextLimit (sync — no provider access needed) ────────────────────────── /// Execute the /contextlimit command (sync — no provider access needed). pub fn execute_context_limit(arg: Option<&str>) -> Result { @@ -154,7 +115,6 @@ pub fn execute_context_limit(arg: Option<&str>) -> Result } if a == "auto" || a == "default" { - // Clear the limit let mut settings = load_settings(); settings.context_limit = None; save_settings(&settings); @@ -167,7 +127,6 @@ pub fn execute_context_limit(arg: Option<&str>) -> Result match a.parse::() { Ok(limit) if limit > 0 => { - // Update settings let mut settings = load_settings(); settings.context_limit = Some(limit); save_settings(&settings); @@ -185,7 +144,7 @@ pub fn execute_context_limit(arg: Option<&str>) -> Result } } -// ── AutoAccept (sync) ─────────────────────────────────────────────────────── +// ── AutoAccept (sync — no provider access needed) ───────────────────────────── /// Execute the /autoaccept command (sync — no provider access needed). pub fn execute_autoaccept(arg: Option<&str>) -> Result { @@ -218,7 +177,6 @@ pub fn execute_autoaccept(arg: Option<&str>) -> Result { } }; - // Update settings let mut settings = load_settings(); settings.auto_accept_safe_commands = new_value; save_settings(&settings); diff --git a/src/commands/init.rs b/src/commands/init.rs index 0ed1604..74a6426 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -3,12 +3,10 @@ use std::path::PathBuf; use tinyharness_lib::context::{PROJECT_MD_FILE_NAMES, WorkspaceContext}; use tinyharness_lib::provider::{Message, Provider, Role}; -use crate::commands::registry::{Command, CommandContext, CommandResult}; +use crate::async_command; +use crate::commands::registry::CommandResult; use crate::style::*; -use std::future::Future; -use std::pin::Pin; - /// Result of the `/init` command. pub enum InitResult { /// The file was created from scratch. @@ -19,33 +17,20 @@ pub enum InitResult { // ── Command trait implementation ──────────────────────────────────────────── -pub struct InitCommand; - -impl Command for InitCommand { - fn name(&self) -> &'static str { - "/init" - } - - fn description(&self) -> &'static str { - "Generate or update TINYHARNESS.md project instructions" - } - - fn execute<'a>( - &self, - _raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { +async_command!( + InitCommand, + "/init", + "Generate or update TINYHARNESS.md project instructions", + |_raw_arg, ctx, messages| { let provider = ctx.provider.clone(); let workspace_ctx = ctx.workspace_ctx.clone(); - - Box::pin(async move { + async move { let mut p = provider.lock().await; let result = execute_init(&mut *p, &workspace_ctx, messages).await?; Ok(CommandResult::Init(result)) - }) + } } -} +); // ── Core implementation ───────────────────────────────────────────────────── diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e3c7b88..ace0720 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -27,12 +27,12 @@ use crate::commands::{ compact::CompactCommand, config_settings::{RetriesCommand, TimeoutCommand}, init::InitCommand, - models::{ModelCommand, ModelsCommand}, + models::ModelsCommand, }; pub use files::FileContext; pub use init::InitResult; -pub use registry::{AliasCommand, CommandContext, CommandRegistry, CommandResult}; +pub use registry::{CommandContext, CommandRegistry, CommandResult}; /// Build the default command registry with all built-in commands. pub fn build_registry() -> CommandRegistry { @@ -319,7 +319,12 @@ pub fn build_registry() -> CommandRegistry { // ── Async commands (need provider.lock().await) ──────────────────────── reg.register(ModelsCommand); - reg.register(ModelCommand); + reg.register_alias( + "/model", + "/models", + None, + "Switch to a different model (alias for /models)", + ); reg.register(CompactCommand); reg.register(InitCommand); reg.register(TimeoutCommand); diff --git a/src/commands/models.rs b/src/commands/models.rs index c4c5cd4..401513b 100644 --- a/src/commands/models.rs +++ b/src/commands/models.rs @@ -1,39 +1,23 @@ use tinyharness_lib::config::{load_settings, save_settings}; -use tinyharness_lib::provider::{Message, Provider}; +use tinyharness_lib::provider::Provider; -use crate::commands::registry::{Command, CommandContext, CommandResult}; +use crate::async_command; +use crate::commands::registry::CommandResult; use crate::style::*; -use std::future::Future; -use std::pin::Pin; - -pub struct ModelsCommand; - -impl Command for ModelsCommand { - fn name(&self) -> &'static str { - "/models" - } - - fn description(&self) -> &'static str { - "List available models" - } - - fn execute<'a>( - &'a self, - raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - _messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { +async_command!( + ModelsCommand, + "/models", + "List available models or switch to a different model", + "/models [name]", + |raw_arg, ctx, _messages| { let name = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); - - Box::pin(async move { + async move { if name.is_empty() { - // No argument — list available models and show current let p = provider.lock().await; execute_list(&*p).await?; - // Show current selection if let Some(model) = p.current_model() { println!( "{}Current model: {}{}{}{}", @@ -48,42 +32,14 @@ impl Command for ModelsCommand { let mut p = provider.lock().await; execute_select(&mut *p, &name).await?; - // Auto-save model let mut settings = load_settings(); settings.last_model = p.current_model(); save_settings(&settings); Ok(CommandResult::Ok) - }) - } -} - -/// Also handle "/model " as a separate command that delegates -pub struct ModelCommand; - -impl Command for ModelCommand { - fn name(&self) -> &'static str { - "/model" - } - - fn description(&self) -> &'static str { - "Switch to a different model" - } - - fn usage(&self) -> &'static str { - "/model " - } - - fn execute<'a>( - &'a self, - raw_arg: Option<&str>, - ctx: &'a mut CommandContext, - messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { - // Delegate to ModelsCommand with the arg - ModelsCommand.execute(raw_arg, ctx, messages) + } } -} +); pub async fn execute_list(provider: &dyn Provider) -> Result<(), String> { let models = provider.list_models().await; @@ -106,7 +62,6 @@ pub async fn execute_select(provider: &mut dyn Provider, name: &str) -> Result<( println!("{}Switched to model: {}{}{}", BOLD, BLUE, name, RESET); Ok(()) } else { - // Still switch even if not in list (model might be pullable) provider.select_model(name.to_string()); println!("{}Set model to: {}{}{}", BOLD, BLUE, name, RESET); Ok(()) diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 9deccc3..90745a5 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -136,18 +136,15 @@ impl CommandContext { /// A self-contained command definition. /// -/// Each command implements this trait to provide its name, aliases, description, +/// Each command implements this trait to provide its name, description, /// usage string, and execution logic. The registry dispatches user input to the /// appropriate `Command` implementation. +/// +/// Aliases are registered separately via [`CommandRegistry::register_alias`]. pub trait Command: Send + Sync { /// Primary name (e.g., "/help"). fn name(&self) -> &'static str; - /// Aliases that also invoke this command (e.g., `["/quit"]` for exit). - fn aliases(&self) -> &'static [&'static str] { - &[] - } - /// One-line description for /help. fn description(&self) -> &'static str; @@ -186,7 +183,6 @@ where pub name_str: &'static str, pub description_str: &'static str, pub usage_str: &'static str, - pub aliases_str: &'static [&'static str], pub handler: F, } @@ -200,10 +196,6 @@ where self.name_str } - fn aliases(&self) -> &'static [&'static str] { - self.aliases_str - } - fn description(&self) -> &'static str { self.description_str } @@ -226,47 +218,66 @@ where } } -// ── AliasCommand ───────────────────────────────────────────────────────────── +// ── async_command! macro ───────────────────────────────────────────────────── -/// A lightweight alias that delegates to another command with an optional fixed argument. +/// Macro to define an async `Command` implementation with minimal boilerplate. /// -/// For example, `/plan` is an alias for `/mode` with the fixed arg `"planning"`. -pub struct AliasCommand { - pub alias_name: &'static str, - pub target_name: &'static str, - /// If set, this arg is passed to the target instead of the user's arg. - pub fixed_arg: Option<&'static str>, - pub description: &'static str, -} - -impl Command for AliasCommand { - fn name(&self) -> &'static str { - self.alias_name - } - - fn aliases(&self) -> &'static [&'static str] { - &[] - } - - fn description(&self) -> &'static str { - self.description - } - - fn usage(&self) -> &'static str { - self.alias_name - } - - fn execute<'a>( - &self, - raw_arg: Option<&str>, - _ctx: &'a mut CommandContext, - _messages: &'a mut Vec, - ) -> Pin> + Send + 'a>> { - // Aliases are resolved by the registry's dispatch method. - // This method should never be called directly. - let _ = raw_arg; - Box::pin(async { Ok(CommandResult::Ok) }) - } +/// Generates a unit struct and `impl Command` that: +/// - Returns the given `name`, `description`, and optional `usage` +/// - In `execute`, makes the provided bindings available and returns the async body +/// +/// # Example +/// ```ignore +/// async_command!(TimeoutCommand, "/timeout", "Show or set timeout", "/timeout [secs]", +/// |raw_arg, ctx, _messages| { +/// let arg = raw_arg.unwrap_or("").to_string(); +/// let provider = ctx.provider.clone(); +/// async move { +/// // ... async logic ... +/// Ok(CommandResult::Ok) +/// } +/// } +/// ); +/// ``` +#[macro_export] +macro_rules! async_command { + // With explicit usage string + ( + $(#[$meta:meta])* + $struct_name:ident, $name:literal, $desc:literal, $usage:literal, + |$raw_arg:ident, $ctx:ident, $messages:ident| $body:expr + ) => { + $(#[$meta])* + pub struct $struct_name; + + impl $crate::commands::registry::Command for $struct_name { + fn name(&self) -> &'static str { $name } + fn description(&self) -> &'static str { $desc } + fn usage(&self) -> &'static str { $usage } + + fn execute<'a>( + &'a self, + $raw_arg: Option<&str>, + $ctx: &'a mut $crate::commands::registry::CommandContext, + $messages: &'a mut Vec, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin($body) + } + } + }; + + // Without explicit usage (defaults to command name) + ( + $(#[$meta:meta])* + $struct_name:ident, $name:literal, $desc:literal, + |$raw_arg:ident, $ctx:ident, $messages:ident| $body:expr + ) => { + async_command!( + $(#[$meta])* + $struct_name, $name, $desc, $name, + |$raw_arg, $ctx, $messages| $body + ); + }; } // ── CommandRegistry ────────────────────────────────────────────────────────── @@ -279,6 +290,8 @@ pub struct CommandRegistry { aliases: HashMap<&'static str, &'static str>, /// Fixed args for aliases (alias → arg to pass instead of user input). alias_fixed_args: HashMap<&'static str, &'static str>, + /// Alias descriptions for /help display. + alias_descriptions: HashMap<&'static str, &'static str>, } impl Default for CommandRegistry { @@ -293,20 +306,14 @@ impl CommandRegistry { commands: HashMap::new(), aliases: HashMap::new(), alias_fixed_args: HashMap::new(), + alias_descriptions: HashMap::new(), } } /// Register a command implementation. - /// Also registers any aliases the command declares. pub fn register(&mut self, cmd: impl Command + 'static) { let name = cmd.name(); - let aliases = cmd.aliases(); self.commands.insert(name, Box::new(cmd)); - - // Register aliases that point to the primary name - for alias in aliases { - self.aliases.insert(alias, name); - } } /// Register an alias for an existing command. @@ -324,8 +331,7 @@ impl CommandRegistry { if let Some(arg) = fixed_arg { self.alias_fixed_args.insert(alias, arg); } - // Store the description so it appears in /help - let _ = description; // Used by command_descriptions() + self.alias_descriptions.insert(alias, description); } /// Register a synchronous command using a closure. @@ -353,10 +359,9 @@ impl CommandRegistry { name_str: name, description_str: description, usage_str: name, - aliases_str: &[], handler, }; - self.register(cmd); + self.commands.insert(name, Box::new(cmd)); } /// Register a synchronous command with a custom usage string. @@ -380,10 +385,9 @@ impl CommandRegistry { name_str: name, description_str: description, usage_str: usage, - aliases_str: &[], handler, }; - self.register(cmd); + self.commands.insert(name, Box::new(cmd)); } /// Parse user input and dispatch to the appropriate command handler. @@ -446,13 +450,19 @@ impl CommandRegistry { .map(|cmd| (cmd.usage(), cmd.description())) .collect(); - // Add alias entries + // Add alias entries with their custom descriptions for (&alias, &target) in &self.aliases { - if let Some(cmd) = self.commands.get(target) { - let desc = cmd.description(); - // Check if this alias has a custom description stored - descs.push((alias, desc)); - } + let desc = self + .alias_descriptions + .get(alias) + .copied() + .unwrap_or_else(|| { + self.commands + .get(target) + .map(|cmd| cmd.description()) + .unwrap_or("") + }); + descs.push((alias, desc)); } descs.sort_by(|a, b| a.0.cmp(b.0)); From e755f76781c70f3e2be7ae19c5fa10276758216c Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Mon, 18 May 2026 09:51:12 +0200 Subject: [PATCH 3/4] fix: use Ollama prompt_eval_count for accurate context token tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adjust heuristic constants (chars/token 4.0→3.2, tokens/word 1.3→1.6, overhead 2→4) to better match real tokenizer behavior for tool outputs - Capture ground-truth prompt_eval_count from Ollama's final_data on every chat call, falling back to heuristic only when message count has changed - Invalidate cached count after slash commands that may mutate messages --- src/agent/mod.rs | 21 ++++++++++++++++++++- tinyharness-lib/src/token.rs | 8 ++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/agent/mod.rs b/src/agent/mod.rs index bad774c..1ff3361 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -115,6 +115,13 @@ pub async fn run_agent_loop( .map(ContextWindowSize::Custom) .unwrap_or_else(ContextWindowSize::default_size); + // Track the last known prompt token count and the message count at that time. + // The LLM provider reports the exact tokenized size of the prompt on every call + // (Ollama: prompt_eval_count, OpenAI-compat: usage.prompt_tokens). We use that + // ground-truth value as long as the message count hasn't changed since it was + // recorded. Falls back to the heuristic estimate_conversation_tokens() otherwise. + let mut last_known_prompt_tokens: Option<(u32, usize)> = None; + loop { // Clear any stale interrupt flag from a previous turn. interrupted.store(false, Ordering::SeqCst); @@ -127,10 +134,14 @@ pub async fn run_agent_loop( AgentMode::Research => ORANGE, }; let pinned_count = ctx.file_context.pinned_file_count(); + let conversation_tokens = last_known_prompt_tokens + .filter(|(_, when_count)| *when_count == messages.len()) + .map(|(tokens, _)| tokens) + .unwrap_or_else(|| estimate_conversation_tokens(messages)); let status_line = display::format_context_status( messages.len(), pinned_count, - estimate_conversation_tokens(messages), + conversation_tokens, context_size, ); @@ -338,6 +349,9 @@ pub async fn run_agent_loop( if ctx.exit_requested { break; } + // Any slash command may have mutated messages (system prompt changes, + // file pinning, mode switch, etc.) — invalidate the cached token count. + last_known_prompt_tokens = None; continue; } @@ -400,6 +414,11 @@ pub async fn run_agent_loop( if msg.done { received_done = true; + // Capture the ground-truth prompt token count from the + // LLM provider (Ollama: prompt_eval_count, OpenAI-compat: usage). + if let Some(ref usage) = msg.usage { + last_known_prompt_tokens = Some((usage.prompt_tokens, messages.len())); + } } if msg.is_error { diff --git a/tinyharness-lib/src/token.rs b/tinyharness-lib/src/token.rs index 5cc9b1a..07d4ce7 100644 --- a/tinyharness-lib/src/token.rs +++ b/tinyharness-lib/src/token.rs @@ -1,7 +1,7 @@ use crate::provider::Message; -const AVG_TOKENS_PER_WORD: f64 = 1.3; -const AVG_CHARS_PER_TOKEN: f64 = 4.0; +const AVG_TOKENS_PER_WORD: f64 = 1.6; +const AVG_CHARS_PER_TOKEN: f64 = 3.2; /// A token usage estimate produced by local estimation (not from a provider). /// @@ -40,8 +40,8 @@ pub fn estimate_tokens(text: &str) -> u32 { pub fn estimate_message_tokens(content: &str, has_tool_calls: bool) -> u32 { let base_tokens = estimate_tokens(content); - let role_overhead = 2; - let tool_call_overhead = if has_tool_calls { 10 } else { 0 }; + let role_overhead = 4; + let tool_call_overhead = if has_tool_calls { 12 } else { 0 }; base_tokens + role_overhead + tool_call_overhead } From 03aef904ba0d660bd98cdb7c697207381eff9d9d Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Mon, 18 May 2026 10:24:53 +0200 Subject: [PATCH 4/4] fix: handle JSON numeric tool arguments (timeout, etc.) The execute_tool_call conversion from serde_json::Value to HashMap used v.as_str() which only works for JSON string values. When the LLM emits numeric params like "timeout": 60000 (common with many models), as_str() returned None, producing "" which silently failed parse::() and fell back to the 30000 default. Fixed by matching on Value::String vs everything else, using to_string() for non-string values (Number, Bool, etc.). --- src/commands/compact.rs | 2 +- src/commands/mod.rs | 99 +++++++++++++++++-------------- src/commands/mode.rs | 3 +- src/commands/registry.rs | 25 ++++---- src/commands/rename.rs | 11 ---- tinyharness-lib/src/tools/tool.rs | 10 +++- 6 files changed, 80 insertions(+), 70 deletions(-) delete mode 100644 src/commands/rename.rs diff --git a/src/commands/compact.rs b/src/commands/compact.rs index 969a2ec..8ac437d 100644 --- a/src/commands/compact.rs +++ b/src/commands/compact.rs @@ -456,7 +456,7 @@ mod tests { #[test] fn test_format_messages_all_roles() { - let msgs = vec![ + let msgs = [ Message { role: Role::System, content: "sys".to_string(), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ace0720..eacad2e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,7 +12,6 @@ pub mod init; pub mod mode; pub mod models; pub mod registry; -pub mod rename; pub mod sessions; pub mod settings; pub mod skill; @@ -34,6 +33,15 @@ pub use files::FileContext; pub use init::InitResult; pub use registry::{CommandContext, CommandRegistry, CommandResult}; +/// Require a non-empty argument. Returns `Err(usage_message)` if the argument +/// is missing or empty, standardizing error messages across commands. +pub fn require_arg<'a>(arg: Option<&'a str>, usage: &str) -> Result<&'a str, String> { + match arg { + Some(s) if !s.is_empty() => Ok(s), + _ => Err(format!("Usage: {}", usage)), + } +} + /// Build the default command registry with all built-in commands. pub fn build_registry() -> CommandRegistry { let mut reg = CommandRegistry::new(); @@ -64,21 +72,22 @@ pub fn build_registry() -> CommandRegistry { "/rename", "Rename the current session", "/rename ", - |arg, _ctx, _msg| crate::commands::rename::execute(arg.unwrap_or("")), + |arg, _ctx, _msg| { + let name = require_arg( + arg, + "/rename — give the current session a descriptive name", + )?; + Ok(CommandResult::RenameSession(name.to_string())) + }, ); - reg.register_sync("/help", "Show this help message", |_arg, ctx, _msg| { - crate::commands::help::execute(&ctx.command_descriptions); - Ok(CommandResult::Ok) - }); - // ── Mode ────────────────────────────────────────────────────────────── reg.register_sync_with_usage( "/mode", "Show or switch mode (casual/planning/agent/research)", "/mode [mode]", - crate::commands::mode::execute, + |arg, ctx, msg| crate::commands::mode::execute(arg, ctx, msg), ); // ── Settings ─────────────────────────────────────────────────────────── @@ -149,22 +158,27 @@ pub fn build_registry() -> CommandRegistry { Ok(CommandResult::Ok) }); - reg.register_sync_with_usage("/session", "Switch to an existing session (accepts ID prefix)", "/session ", + reg.register_sync_with_usage( + "/session", + "Switch to an existing session (accepts ID prefix)", + "/session ", |arg, ctx, _msg| { - let a = arg.unwrap_or("").to_string(); - if a.is_empty() { - return Err("Usage: /session — use /sessions to list available sessions".to_string()); - } + let a = require_arg( + arg, + "/session — use /sessions to list available sessions", + )?; if a.starts_with("delete ") || a == "delete" { - let id = a.strip_prefix("delete ").unwrap_or("").trim().to_string(); - if id.is_empty() { - return Err("Usage: /session delete — use /sessions to list available sessions".to_string()); - } - crate::commands::sessions::execute_delete(&id, ctx.session_id.as_deref()); + let id = a.strip_prefix("delete ").unwrap_or("").trim(); + let id = require_arg( + if id.is_empty() { None } else { Some(id) }, + "/session delete — use /sessions to list available sessions", + )?; + crate::commands::sessions::execute_delete(id, ctx.session_id.as_deref()); return Ok(CommandResult::Ok); } - Ok(CommandResult::SwitchSession(a)) - }); + Ok(CommandResult::SwitchSession(a.to_string())) + }, + ); // ── File pinning ────────────────────────────────────────────────────── @@ -173,11 +187,8 @@ pub fn build_registry() -> CommandRegistry { "Pin a file into the AI's context so it's always available", "/add ", |arg, ctx, msg| { - let path = arg.unwrap_or("").to_string(); - if path.is_empty() { - return Err("Usage: /add — e.g. /add src/main.rs".to_string()); - } - crate::commands::files::execute_add(&mut ctx.file_context, &path); + let path = require_arg(arg, "/add — e.g. /add src/main.rs")?; + crate::commands::files::execute_add(&mut ctx.file_context, path); ctx.refresh_system_prompt(msg); Ok(CommandResult::Ok) }, @@ -188,11 +199,8 @@ pub fn build_registry() -> CommandRegistry { "Remove a pinned file from context", "/drop ", |arg, ctx, msg| { - let path = arg.unwrap_or("").to_string(); - if path.is_empty() { - return Err("Usage: /drop — e.g. /drop src/main.rs".to_string()); - } - crate::commands::files::execute_drop(&mut ctx.file_context, &path); + let path = require_arg(arg, "/drop — e.g. /drop src/main.rs")?; + crate::commands::files::execute_drop(&mut ctx.file_context, path); ctx.refresh_system_prompt(msg); Ok(CommandResult::Ok) }, @@ -269,12 +277,8 @@ pub fn build_registry() -> CommandRegistry { "Activate a skill, injecting its instructions into the conversation", "/use ", |arg, ctx, _msg| { - let name = arg.unwrap_or("").to_string(); - if name.is_empty() { - crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); - return Ok(CommandResult::Ok); - } - crate::commands::skill::handle_skill_use(&name, ctx) + let name = require_arg(arg, "/use ")?; + crate::commands::skill::handle_skill_use(name, ctx) }, ); @@ -283,17 +287,13 @@ pub fn build_registry() -> CommandRegistry { "Deactivate a previously loaded skill", "/unload ", |arg, ctx, _msg| { - let name = arg.unwrap_or("").to_string(); - if name.is_empty() { - crate::commands::skill::execute_list(&ctx.skill_registry, &ctx.active_skills); - return Ok(CommandResult::Ok); - } + let name = require_arg(arg, "/unload ")?; if ctx .active_skills .iter() - .any(|s| s.eq_ignore_ascii_case(&name)) + .any(|s| s.eq_ignore_ascii_case(name)) { - Ok(CommandResult::SkillUnload(name)) + Ok(CommandResult::SkillUnload(name.to_string())) } else { println!( "{}Skill '{}' is not currently active.{}", @@ -361,6 +361,19 @@ pub fn build_registry() -> CommandRegistry { // Exit alias: /quit → /exit reg.register_alias("/quit", "/exit", None, "Exit the application"); + // ── Help (registered last, after descriptions are frozen) ───────────── + + reg.freeze_descriptions(); + let descs = reg.descriptions().to_vec(); + reg.register_sync( + "/help", + "Show this help message", + move |_arg, _ctx, _msg| { + crate::commands::help::execute(&descs); + Ok(CommandResult::Ok) + }, + ); + reg } diff --git a/src/commands/mode.rs b/src/commands/mode.rs index d232ccd..d1c9f84 100644 --- a/src/commands/mode.rs +++ b/src/commands/mode.rs @@ -5,11 +5,10 @@ use crate::commands::registry::{CommandContext, CommandResult}; use crate::style::*; /// Execute the /mode command. -#[allow(clippy::ptr_arg)] pub fn execute( arg: Option<&str>, ctx: &mut CommandContext, - messages: &mut Vec, + messages: &mut [Message], ) -> Result { let mode_str = arg.unwrap_or(""); diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 90745a5..74e065b 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -45,8 +45,6 @@ pub struct CommandContext { pub skill_registry: SkillRegistry, /// Names of currently active (loaded) skills. pub active_skills: Vec, - /// Cached command descriptions from the registry (for /help). - pub command_descriptions: Vec<(&'static str, &'static str)>, } impl CommandContext { @@ -63,7 +61,6 @@ impl CommandContext { session_id: None, skill_registry: SkillRegistry::discover(), active_skills: Vec::new(), - command_descriptions: Vec::new(), } } @@ -292,6 +289,9 @@ pub struct CommandRegistry { alias_fixed_args: HashMap<&'static str, &'static str>, /// Alias descriptions for /help display. alias_descriptions: HashMap<&'static str, &'static str>, + /// Pre-computed (usage, description) pairs for /help, including aliases. + /// Populated by [`freeze_descriptions`] after all registrations are done. + descriptions: Vec<(&'static str, &'static str)>, } impl Default for CommandRegistry { @@ -307,6 +307,7 @@ impl CommandRegistry { aliases: HashMap::new(), alias_fixed_args: HashMap::new(), alias_descriptions: HashMap::new(), + descriptions: Vec::new(), } } @@ -426,9 +427,6 @@ impl CommandRegistry { .get(resolved_name) .ok_or_else(|| format!("Unknown command: {}", cmd_name))?; - // Populate command descriptions for /help - ctx.command_descriptions = self.command_descriptions(); - handler.execute(effective_arg, ctx, messages).await } @@ -440,17 +438,15 @@ impl CommandRegistry { names } - /// Get (usage, description) pairs for /help display. - /// - /// Returns entries from registered commands plus alias entries. - pub fn command_descriptions(&self) -> Vec<(&'static str, &'static str)> { + /// Freeze the pre-computed (usage, description) pairs for /help display. + /// Must be called after all commands and aliases have been registered. + pub fn freeze_descriptions(&mut self) { let mut descs: Vec<(&'static str, &'static str)> = self .commands .values() .map(|cmd| (cmd.usage(), cmd.description())) .collect(); - // Add alias entries with their custom descriptions for (&alias, &target) in &self.aliases { let desc = self .alias_descriptions @@ -466,7 +462,12 @@ impl CommandRegistry { } descs.sort_by(|a, b| a.0.cmp(b.0)); - descs + self.descriptions = descs; + } + + /// Get the frozen (usage, description) pairs for /help display. + pub fn descriptions(&self) -> &[(&'static str, &'static str)] { + &self.descriptions } /// Check if a command name (or alias) is registered. diff --git a/src/commands/rename.rs b/src/commands/rename.rs deleted file mode 100644 index d8ba5f5..0000000 --- a/src/commands/rename.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::commands::registry::CommandResult; - -pub fn execute(name: &str) -> Result { - if name.is_empty() { - return Err( - "Usage: /rename — give the current session a descriptive name".to_string(), - ); - } - - Ok(CommandResult::RenameSession(name.to_string())) -} diff --git a/tinyharness-lib/src/tools/tool.rs b/tinyharness-lib/src/tools/tool.rs index ca087f3..ecfabcc 100644 --- a/tinyharness-lib/src/tools/tool.rs +++ b/tinyharness-lib/src/tools/tool.rs @@ -68,7 +68,15 @@ pub async fn execute_tool_call(tool: &Tool, arguments: &serde_json::Value) -> St .as_object() .map(|obj| { obj.iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())) + .map(|(k, v)| { + // Use to_string() instead of as_str() so JSON numbers (e.g. "timeout": 60000) + // are correctly converted to "60000" rather than silently becoming "". + let val = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), val) + }) .collect() }) .unwrap_or_default();