diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 92b0745..b1aa69c 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,3 +1,8 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; @@ -34,6 +39,7 @@ impl CommandRegistry { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SlashCommandSpec { pub name: &'static str, + pub aliases: &'static [&'static str], pub summary: &'static str, pub argument_hint: Option<&'static str>, pub resume_supported: bool, @@ -581,9 +587,10 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_plugins_slash_command, handle_slash_command, render_plugins_report, + handle_plugins_slash_command, handle_slash_command, load_agents_from_roots, + load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - SlashCommand, + DefinitionSource, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -921,6 +928,86 @@ mod tests { assert!(rendered.contains("disabled")); } + #[test] + fn lists_agents_from_project_and_user_roots() { + let workspace = temp_dir("agents-workspace"); + let project_agents = workspace.join(".codex").join("agents"); + let user_home = temp_dir("agents-home"); + let user_agents = user_home.join(".codex").join("agents"); + + write_agent( + &project_agents, + "planner", + "Project planner", + "gpt-5.4", + "medium", + ); + write_agent( + &user_agents, + "planner", + "User planner", + "gpt-5.4-mini", + "high", + ); + write_agent( + &user_agents, + "verifier", + "Verification agent", + "gpt-5.4-mini", + "high", + ); + + let roots = vec![ + (DefinitionSource::ProjectCodex, project_agents), + (DefinitionSource::UserCodex, user_agents), + ]; + let report = render_agents_report( + &load_agents_from_roots(&roots).expect("agent roots should load"), + ); + + assert!(report.contains("Agents")); + assert!(report.contains("2 active agents")); + assert!(report.contains("Project (.codex):")); + assert!(report.contains("planner · Project planner · gpt-5.4 · medium")); + assert!(report.contains("User (~/.codex):")); + assert!(report.contains("(shadowed by Project (.codex)) planner · User planner")); + assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high")); + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(user_home); + } + + #[test] + fn lists_skills_from_project_and_user_roots() { + let workspace = temp_dir("skills-workspace"); + let project_skills = workspace.join(".codex").join("skills"); + let user_home = temp_dir("skills-home"); + let user_skills = user_home.join(".codex").join("skills"); + + write_skill(&project_skills, "plan", "Project planning guidance"); + write_skill(&user_skills, "plan", "User planning guidance"); + write_skill(&user_skills, "help", "Help guidance"); + + let roots = vec![ + (DefinitionSource::ProjectCodex, project_skills), + (DefinitionSource::UserCodex, user_skills), + ]; + let report = render_skills_report( + &load_skills_from_roots(&roots).expect("skill roots should load"), + ); + + assert!(report.contains("Skills")); + assert!(report.contains("2 available skills")); + assert!(report.contains("Project (.codex):")); + assert!(report.contains("plan · Project planning guidance")); + assert!(report.contains("User (~/.codex):")); + assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance")); + assert!(report.contains("help · Help guidance")); + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(user_home); + } + #[test] fn installs_plugin_from_path_and_lists_it() { let config_home = temp_dir("home"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d770960..7c3179c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -987,6 +987,7 @@ impl LiveCli { true, allowed_tools.clone(), permission_mode, + None, )?; let cli = Self { model, @@ -1084,6 +1085,7 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, + None, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; @@ -1265,6 +1267,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.model.clone_from(&model); println!( @@ -1308,6 +1311,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "{}", @@ -1333,6 +1337,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", @@ -1368,6 +1373,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1440,6 +1446,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1483,6 +1490,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.persist_session() } @@ -1500,16 +1508,18 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } - fn run_internal_prompt_text( + fn run_internal_prompt_text_with_progress( &self, prompt: &str, enable_tools: bool, + progress: Option, ) -> Result> { let session = self.runtime.session().clone(); let mut runtime = build_runtime( @@ -1520,12 +1530,21 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, + progress, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; Ok(final_assistant_text(&summary).trim().to_string()) } + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { let scope = scope.unwrap_or("the current repository"); let prompt = format!( @@ -1540,8 +1559,22 @@ impl LiveCli { let prompt = format!( "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." ); - println!("{}", self.run_internal_prompt_text(&prompt, true)?); - Ok(()) + let mut progress = InternalPromptProgressRun::start_ultraplan(task); + match self.run_internal_prompt_text_with_progress( + &prompt, + true, + Some(progress.reporter()), + ) { + Ok(plan) => { + progress.finish_success(); + println!("{plan}"); + Ok(()) + } + Err(error) => { + progress.finish_failure(&error.to_string()); + Err(error) + } + } } #[allow(clippy::unused_self)] @@ -2375,6 +2408,330 @@ fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct InternalPromptProgressState { + command_label: &'static str, + task_label: String, + step: usize, + phase: String, + detail: Option, + saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +struct InternalPromptProgressShared { + state: Mutex, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct InternalPromptProgressReporter { + shared: Arc, +} + +#[derive(Debug)] +struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option>, + heartbeat_handle: Option>, +} + +impl InternalPromptProgressReporter { + fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = + format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || { + loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter.emit(InternalPromptProgressEvent::Complete, None); + } + + fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot.detail.as_deref().filter(|detail| !detail.is_empty()) { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" · "); + match event { + InternalPromptProgressEvent::Started => { + format!("🧭 {} status · planning started · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Update => { + format!("… {} status · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Heartbeat => format!( + "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", + snapshot.command_label + ), + InternalPromptProgressEvent::Complete => format!( + "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", + snapshot.command_label, + snapshot.step + ), + InternalPromptProgressEvent::Failed => format!( + "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", + snapshot.command_label, + error.unwrap_or("unknown error") + ), + } +} + +fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + #[allow(clippy::needless_pass_by_value)] fn build_runtime( session: Session, @@ -2384,6 +2741,7 @@ fn build_runtime( emit_output: bool, allowed_tools: Option, permission_mode: PermissionMode, + progress_reporter: Option, ) -> Result, Box> { let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?; @@ -2395,6 +2753,7 @@ fn build_runtime( emit_output, allowed_tools.clone(), tool_registry.clone(), + progress_reporter, )?, CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), permission_policy(permission_mode, &tool_registry), @@ -2458,6 +2817,7 @@ struct AnthropicRuntimeClient { emit_output: bool, allowed_tools: Option, tool_registry: GlobalToolRegistry, + progress_reporter: Option, } impl AnthropicRuntimeClient { @@ -2467,6 +2827,7 @@ impl AnthropicRuntimeClient { emit_output: bool, allowed_tools: Option, tool_registry: GlobalToolRegistry, + progress_reporter: Option, ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, @@ -2477,6 +2838,7 @@ impl AnthropicRuntimeClient { emit_output, allowed_tools, tool_registry, + progress_reporter, }) } } @@ -2494,6 +2856,9 @@ fn resolve_cli_auth_source() -> Result> { impl ApiClient for AnthropicRuntimeClient { #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } let message_request = MessageRequest { model: self.model.clone(), max_tokens: max_tokens_for_model(&self.model), @@ -2548,6 +2913,9 @@ impl ApiClient for AnthropicRuntimeClient { ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } if let Some(rendered) = markdown_stream.push(&renderer, &text) { write!(out, "{rendered}") .and_then(|()| out.flush()) @@ -2571,6 +2939,9 @@ impl ApiClient for AnthropicRuntimeClient { .map_err(|error| RuntimeError::new(error.to_string()))?; } if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } // Display tool call now that input is fully accumulated writeln!(out, "\n{}", format_tool_call_start(&name, &input)) .and_then(|()| out.flush()) @@ -3384,19 +3755,23 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_resume_report, format_status_report, format_tool_call_start, format_tool_result, - normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, - print_help_to, push_output_block, render_config_report, render_memory_report, - render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, - status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, + format_internal_prompt_progress_line, format_model_report, + format_model_switch_report, format_permissions_report, + format_permissions_switch_report, format_resume_report, format_status_report, + format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, + parse_git_status_metadata, permission_policy, print_help_to, push_output_block, + render_config_report, render_memory_report, render_repl_help, resolve_model_alias, + response_to_events, resume_supported_slash_commands, status_context, CliAction, + CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use serde_json::json; use std::path::PathBuf; + use std::time::Duration; use tools::GlobalToolRegistry; fn registry_with_plugin_tool() -> GlobalToolRegistry {