feat: command surface and slash completion wiring
This commit is contained in:
@@ -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 plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{compact_session, CompactionConfig, Session};
|
||||||
|
|
||||||
@@ -34,6 +39,7 @@ impl CommandRegistry {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct SlashCommandSpec {
|
pub struct SlashCommandSpec {
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
pub aliases: &'static [&'static str],
|
||||||
pub summary: &'static str,
|
pub summary: &'static str,
|
||||||
pub argument_hint: Option<&'static str>,
|
pub argument_hint: Option<&'static str>,
|
||||||
pub resume_supported: bool,
|
pub resume_supported: bool,
|
||||||
@@ -581,9 +587,10 @@ pub fn handle_slash_command(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
SlashCommand,
|
DefinitionSource, SlashCommand,
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
@@ -921,6 +928,86 @@ mod tests {
|
|||||||
assert!(rendered.contains("disabled"));
|
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]
|
#[test]
|
||||||
fn installs_plugin_from_path_and_lists_it() {
|
fn installs_plugin_from_path_and_lists_it() {
|
||||||
let config_home = temp_dir("home");
|
let config_home = temp_dir("home");
|
||||||
|
|||||||
@@ -987,6 +987,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
permission_mode,
|
permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
let cli = Self {
|
let cli = Self {
|
||||||
model,
|
model,
|
||||||
@@ -1084,6 +1085,7 @@ impl LiveCli {
|
|||||||
false,
|
false,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let summary = runtime.run_turn(input, Some(&mut permission_prompter))?;
|
let summary = runtime.run_turn(input, Some(&mut permission_prompter))?;
|
||||||
@@ -1265,6 +1267,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.model.clone_from(&model);
|
self.model.clone_from(&model);
|
||||||
println!(
|
println!(
|
||||||
@@ -1308,6 +1311,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@@ -1333,6 +1337,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
println!(
|
println!(
|
||||||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||||||
@@ -1368,6 +1373,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
println!(
|
println!(
|
||||||
@@ -1440,6 +1446,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
println!(
|
println!(
|
||||||
@@ -1483,6 +1490,7 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()
|
self.persist_session()
|
||||||
}
|
}
|
||||||
@@ -1500,16 +1508,18 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
println!("{}", format_compact_report(removed, kept, skipped));
|
println!("{}", format_compact_report(removed, kept, skipped));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_internal_prompt_text(
|
fn run_internal_prompt_text_with_progress(
|
||||||
&self,
|
&self,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
|
progress: Option<InternalPromptProgressReporter>,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
let mut runtime = build_runtime(
|
let mut runtime = build_runtime(
|
||||||
@@ -1520,12 +1530,21 @@ impl LiveCli {
|
|||||||
false,
|
false,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
|
progress,
|
||||||
)?;
|
)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
||||||
Ok(final_assistant_text(&summary).trim().to_string())
|
Ok(final_assistant_text(&summary).trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_internal_prompt_text(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
enable_tools: bool,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let scope = scope.unwrap_or("the current repository");
|
let scope = scope.unwrap_or("the current repository");
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
@@ -1540,8 +1559,22 @@ impl LiveCli {
|
|||||||
let prompt = format!(
|
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."
|
"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)?);
|
let mut progress = InternalPromptProgressRun::start_ultraplan(task);
|
||||||
Ok(())
|
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)]
|
#[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<String>,
|
||||||
|
saw_final_text: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum InternalPromptProgressEvent {
|
||||||
|
Started,
|
||||||
|
Update,
|
||||||
|
Heartbeat,
|
||||||
|
Complete,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InternalPromptProgressShared {
|
||||||
|
state: Mutex<InternalPromptProgressState>,
|
||||||
|
output_lock: Mutex<()>,
|
||||||
|
started_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InternalPromptProgressReporter {
|
||||||
|
shared: Arc<InternalPromptProgressShared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InternalPromptProgressRun {
|
||||||
|
reporter: InternalPromptProgressReporter,
|
||||||
|
heartbeat_stop: Option<mpsc::Sender<()>>,
|
||||||
|
heartbeat_handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn build_runtime(
|
fn build_runtime(
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -2384,6 +2741,7 @@ fn build_runtime(
|
|||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?;
|
let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?;
|
||||||
@@ -2395,6 +2753,7 @@ fn build_runtime(
|
|||||||
emit_output,
|
emit_output,
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
tool_registry.clone(),
|
tool_registry.clone(),
|
||||||
|
progress_reporter,
|
||||||
)?,
|
)?,
|
||||||
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
||||||
permission_policy(permission_mode, &tool_registry),
|
permission_policy(permission_mode, &tool_registry),
|
||||||
@@ -2458,6 +2817,7 @@ struct AnthropicRuntimeClient {
|
|||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnthropicRuntimeClient {
|
impl AnthropicRuntimeClient {
|
||||||
@@ -2467,6 +2827,7 @@ impl AnthropicRuntimeClient {
|
|||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
runtime: tokio::runtime::Runtime::new()?,
|
runtime: tokio::runtime::Runtime::new()?,
|
||||||
@@ -2477,6 +2838,7 @@ impl AnthropicRuntimeClient {
|
|||||||
emit_output,
|
emit_output,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
tool_registry,
|
tool_registry,
|
||||||
|
progress_reporter,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2494,6 +2856,9 @@ fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
|||||||
impl ApiClient for AnthropicRuntimeClient {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
if let Some(progress_reporter) = &self.progress_reporter {
|
||||||
|
progress_reporter.mark_model_phase();
|
||||||
|
}
|
||||||
let message_request = MessageRequest {
|
let message_request = MessageRequest {
|
||||||
model: self.model.clone(),
|
model: self.model.clone(),
|
||||||
max_tokens: max_tokens_for_model(&self.model),
|
max_tokens: max_tokens_for_model(&self.model),
|
||||||
@@ -2548,6 +2913,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
ContentBlockDelta::TextDelta { text } => {
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
if !text.is_empty() {
|
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) {
|
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
|
||||||
write!(out, "{rendered}")
|
write!(out, "{rendered}")
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
@@ -2571,6 +2939,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
}
|
}
|
||||||
if let Some((id, name, input)) = pending_tool.take() {
|
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
|
// Display tool call now that input is fully accumulated
|
||||||
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
@@ -3384,19 +3755,23 @@ fn print_help() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
filter_tool_specs, format_compact_report, format_cost_report, format_model_report,
|
describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report,
|
||||||
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
format_internal_prompt_progress_line, format_model_report,
|
||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_model_switch_report, format_permissions_report,
|
||||||
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
format_permissions_switch_report, format_resume_report, format_status_report,
|
||||||
print_help_to, push_output_block, render_config_report, render_memory_report,
|
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
|
||||||
render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands,
|
parse_git_status_metadata, permission_policy, print_help_to, push_output_block,
|
||||||
status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
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 api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||||
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
use tools::GlobalToolRegistry;
|
use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
fn registry_with_plugin_tool() -> GlobalToolRegistry {
|
fn registry_with_plugin_tool() -> GlobalToolRegistry {
|
||||||
|
|||||||
Reference in New Issue
Block a user