diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index c08548da..c62073bf 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -10,6 +10,7 @@ mod config; mod model; mod provider; +mod skills; mod timeline; mod worktree; @@ -53,10 +54,12 @@ pub struct AgentPaths { pub store_dir: PathBuf, /// Parent of the tasks' worktrees. pub worktrees_dir: PathBuf, - /// Settings and commands each agent offered last time (shared by all projects). + /// Settings each agent offered last time (shared by all projects), and its commands per project. pub models_file: PathBuf, /// User-defined agents, one `.toml` each (`name`, `command`, `args`, `env`). pub custom_agents_dir: Option, + /// The user's home: its `.claude` holds their own skills and commands. + pub home_dir: Option, } /// Starts the ACP server of a provider in a directory, speaking to `client`. @@ -80,8 +83,9 @@ struct Shared { struct Known { #[serde(default)] config: Vec, - #[serde(default)] - commands: Vec, + /// By project root: skills and custom commands differ per project. + #[serde(default, rename = "projectCommands")] + commands: HashMap>, } impl Shared { @@ -96,8 +100,18 @@ impl Shared { } } - fn known(&self, provider: &str) -> Known { - self.known.lock().get(provider).cloned().unwrap_or_default() + fn known_config(&self, provider: &str) -> Vec { + self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default() + } + + /// The commands the agent offered last time in this project. + fn known_commands(&self, provider: &str) -> Vec { + let cache = self.known.lock(); + cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default() + } + + fn root_key(&self) -> String { + self.paths.project_root.to_string_lossy().into_owned() } fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) { @@ -109,8 +123,11 @@ impl Shared { known.config = c.to_vec(); changed = true; } - if let Some(c) = commands.filter(|c| !c.is_empty() && known.commands != *c) { - known.commands = c.to_vec(); + let root = self.root_key(); + if let Some(c) = + commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c)) + { + known.commands.insert(root, c.to_vec()); changed = true; } if !changed { @@ -238,25 +255,42 @@ impl AgentManager { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); let list = specs .iter() - .map(|p| { - let known = self.shared.known(&p.id); - AgentProvider { - id: p.id.clone(), - name: p.name.clone(), - command: std::iter::once(p.command.as_str()) - .chain(p.args.iter().map(String::as_str)) - .collect::>() - .join(" "), - installed: provider::installed(&p.command), - config: known.config, - commands: known.commands, - } + .map(|p| AgentProvider { + id: p.id.clone(), + name: p.name.clone(), + command: std::iter::once(p.command.as_str()) + .chain(p.args.iter().map(String::as_str)) + .collect::>() + .join(" "), + installed: provider::installed(&p.command), + config: self.shared.known_config(&p.id), + commands: self.shared.known_commands(&p.id), }) .collect(); *self.shared.providers.write() = specs; list } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) + /// in the project: what the agent offered last time there, plus, for Claude Code, the + /// skills and custom commands on disk it has not listed. + pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult> { + let (provider, offered, cwd) = match task { + Some(id) => { + let task = self.task(id)?; + let s = task.state.lock(); + (s.task.provider.clone(), s.task.commands.clone(), s.task.cwd.0.clone()) + } + None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()), + }; + if provider != "claude" { + return Ok(offered); + } + let home = self.shared.paths.home_dir.clone(); + let found = blocking(move || Ok(skills::discover(&cwd, home.as_deref()))).await?; + Ok(skills::merge(offered, found)) + } + fn task(&self, id: &str) -> IdeResult> { self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}"))) } @@ -289,8 +323,7 @@ impl AgentManager { } }; // Until the session reports its own, show what the agent offered last time with the choices applied. - let known = self.shared.known(&spec.id); - let mut config = known.config; + let mut config = self.shared.known_config(&spec.id); for choice in &req.config { if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) { o.current = Some(choice.value.clone()); @@ -312,7 +345,7 @@ impl AgentManager { created_ms: now, updated_ms: now, config, - commands: known.commands, + commands: self.shared.known_commands(&spec.id), usage: None, queue: Vec::new(), }; diff --git a/crates/ide-agent/src/skills.rs b/crates/ide-agent/src/skills.rs new file mode 100644 index 00000000..b426fc06 --- /dev/null +++ b/crates/ide-agent/src/skills.rs @@ -0,0 +1,281 @@ +//! Claude Code's slash commands on disk, for completion before (or without) a session: +//! skills (`.claude/skills//SKILL.md`) and custom commands (`.claude/commands/**/*.md`) +//! of the working directory and every directory above it, then the user's `~/.claude`. + +use crate::AgentCommand; +use std::collections::HashSet; +use std::path::Path; + +const MAX_DESCRIPTION: usize = 200; +/// Command directories nest this deep at most (`a/b/c.md` → `a:b:c`). +const MAX_DEPTH: usize = 4; + +/// The commands Claude Code finds from `cwd`: nearest directory first, the user's last; +/// the first of a name wins. +pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut add = |found: Vec| { + for c in found { + if seen.insert(c.name.clone()) { + out.push(c); + } + } + }; + for dir in cwd.ancestors() { + if home.is_some_and(|h| h == dir) { + break; + } + add(in_dir(&dir.join(".claude"))); + } + if let Some(home) = home { + add(in_dir(&home.join(".claude"))); + } + out +} + +/// `first` (the agent's own list) plus the commands of `more` it lacks. +pub(crate) fn merge(first: Vec, more: Vec) -> Vec { + let names: HashSet = first.iter().map(|c| c.name.clone()).collect(); + let mut out = first; + out.extend(more.into_iter().filter(|c| !names.contains(&c.name))); + out +} + +fn in_dir(claude: &Path) -> Vec { + if !claude.is_dir() { + return Vec::new(); + } + let mut out = skills(&claude.join("skills")); + commands(&claude.join("commands"), "", 0, &mut out); + out +} + +fn skills(dir: &Path) -> Vec { + let mut out: Vec = sorted(dir) + .into_iter() + .filter_map(|path| { + let text = std::fs::read_to_string(path.join("SKILL.md")).ok()?; + let meta = Frontmatter::parse(&text); + if meta.get("user-invocable").is_some_and(|v| v.eq_ignore_ascii_case("false")) { + return None; + } + let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?; + Some(meta.command(name, &text)) + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + for path in sorted(dir) { + let Some(name) = file_name(&path) else { continue }; + if path.is_dir() { + commands(&path, &format!("{prefix}{name}:"), depth + 1, out); + } else if let Some(stem) = name.strip_suffix(".md") + && let Ok(text) = std::fs::read_to_string(&path) + { + out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text)); + } + } +} + +fn sorted(dir: &Path) -> Vec { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| file_name(p).is_some_and(|n| !n.starts_with('.'))) + .collect(); + paths.sort(); + paths +} + +fn file_name(path: &Path) -> Option { + path.file_name()?.to_str().map(str::to_owned) +} + +/// The `key: value` lines between the leading `---` fences (folded `>` / `|` values joined). +struct Frontmatter { + fields: Vec<(String, String)>, + /// Where the body starts. + body: usize, +} + +impl Frontmatter { + fn parse(text: &str) -> Self { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut lines = text.split_inclusive('\n'); + let mut offset = 0; + let mut fields: Vec<(String, String)> = Vec::new(); + match lines.next() { + Some(first) if first.trim_end() == "---" => offset += first.len(), + _ => return Self { fields, body: 0 }, + } + for line in lines { + offset += line.len(); + let line = line.trim_end(); + if line == "---" { + return Self { fields, body: offset }; + } + if line.starts_with([' ', '\t']) { + if let Some((_, value)) = fields.last_mut() { + if !value.is_empty() { + value.push(' '); + } + value.push_str(line.trim()); + } + continue; + } + if let Some((key, value)) = line.split_once(':') { + let value = value.trim(); + let value = if matches!(value, ">" | "|" | ">-" | "|-") { "" } else { unquote(value) }; + fields.push((key.trim().to_owned(), value.to_owned())); + } + } + // No closing fence: not frontmatter. + Self { fields: Vec::new(), body: 0 } + } + + fn get(&self, key: &str) -> Option<&str> { + self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + /// `description`, else the body's first line. + fn command(&self, name: String, text: &str) -> AgentCommand { + let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| { + let body = text.get(self.body..).unwrap_or_default(); + body.lines() + .map(|l| l.trim().trim_start_matches('#').trim()) + .find(|l| !l.is_empty()) + .unwrap_or_default() + .to_owned() + }); + AgentCommand { + name, + description: truncate(&description), + hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned), + } + } +} + +fn unquote(value: &str) -> &str { + for q in ['"', '\''] { + if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) { + return inner; + } + } + value +} + +fn truncate(s: &str) -> String { + match s.char_indices().nth(MAX_DESCRIPTION) { + Some((i, _)) => format!("{}…", s[..i].trim_end()), + None => s.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn names(cmds: &[AgentCommand]) -> Vec<&str> { + cmds.iter().map(|c| c.name.as_str()).collect() + } + + fn cmd(name: &str, description: &str) -> AgentCommand { + AgentCommand { name: name.into(), description: description.into(), hint: None } + } + + #[test] + fn project_and_user_skills_and_commands() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let repo = tmp.path().join("work").join("idea"); + let skills = repo.join(".claude/skills"); + write( + &skills.join("triage/SKILL.md"), + "---\nname: triage\ndescription: One pass over open issues and PRs\n---\n# Triage\n", + ); + write(&skills.join("issue-fix/SKILL.md"), "---\nname: issue-fix\ndescription: \"Fix an issue\"\n---\n"); + // No frontmatter name: the directory's. + write(&skills.join("verify/SKILL.md"), "---\ndescription: >\n Build and\n drive the app\n---\n"); + write(&skills.join("hidden/SKILL.md"), "---\nname: hidden\nuser-invocable: false\n---\n"); + write(&skills.join("no-skill-file/README.md"), "nothing"); + write(&repo.join(".claude/commands/review.md"), "---\nargument-hint: \n---\nReview a pull request\n"); + write(&repo.join(".claude/commands/git/commit.md"), "# Commit the staged changes\n"); + // A parent directory's commands apply too. + write(&tmp.path().join("work/.claude/commands/parent.md"), "---\ndescription: from above\n---\n"); + // The user's: shadowed by the project's skill of the same name. + write(&home.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: user triage\n---\n"); + write(&home.join(".claude/skills/lint/SKILL.md"), "---\nname: lint\ndescription: user lint\n---\n"); + write(&home.join(".claude/commands/brooks-review.md"), "---\ndescription: Review\n---\n"); + + let found = discover(&repo, Some(&home)); + assert_eq!( + names(&found), + ["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"] + ); + let by = |n: &str| found.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by("triage").description, "One pass over open issues and PRs"); + assert_eq!(by("issue-fix").description, "Fix an issue"); + assert_eq!(by("verify").description, "Build and drive the app"); + assert_eq!(by("git:commit").description, "Commit the staged changes"); + assert_eq!( + (by("review").description.as_str(), by("review").hint.as_deref()), + ("Review a pull request", Some("")) + ); + } + + #[test] + fn nothing_without_claude_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let repo = home.join("plain"); + std::fs::create_dir_all(&repo).unwrap(); + assert!(discover(&repo, Some(&home)).is_empty()); + } + + #[test] + fn walking_up_stops_at_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + // Above home: not the user's, and not this project's. + write(&tmp.path().join(".claude/commands/outside.md"), "x\n"); + write(&home.join(".claude/commands/mine.md"), "x\n"); + let repo = home.join("src/app"); + std::fs::create_dir_all(&repo).unwrap(); + assert_eq!(names(&discover(&repo, Some(&home))), ["mine"]); + } + + #[test] + fn the_agents_entries_win() { + let merged = merge( + vec![cmd("triage", "from the agent"), cmd("compact", "Compact")], + vec![cmd("triage", "from disk"), cmd("verify", "Verify")], + ); + assert_eq!(names(&merged), ["triage", "compact", "verify"]); + assert_eq!(merged[0].description, "from the agent"); + } + + #[test] + fn frontmatter_edge_cases() { + let f = Frontmatter::parse("no fence\n"); + assert!(f.fields.is_empty()); + let f = Frontmatter::parse("---\nname: x\nunterminated\n"); + assert!(f.get("name").is_none()); + let f = Frontmatter::parse("\u{feff}---\r\nname: 'quoted'\r\n---\r\nbody\r\n"); + assert_eq!(f.get("name"), Some("quoted")); + assert_eq!(truncate(&"é".repeat(MAX_DESCRIPTION + 5)).chars().count(), MAX_DESCRIPTION + 1); + } +} diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index a41b17fc..b98428bc 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -93,6 +93,10 @@ impl Handler for FakeAgent { } fn manager(dir: &Path, agent: Arc) -> AgentManager { + manager_at(dir, &dir.join("models.json"), agent) +} + +fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentManager { let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { let (a, b) = duplex(1 << 20); let (ar, aw) = tokio::io::split(a); @@ -107,14 +111,54 @@ fn manager(dir: &Path, agent: Arc) -> AgentManager { project_root: dir.to_path_buf(), store_dir: dir.join("store"), worktrees_dir: dir.join("worktrees"), - models_file: dir.join("models.json"), + models_file: models_file.to_path_buf(), custom_agents_dir: None, + home_dir: None, }, tokio::runtime::Handle::current(), connector, ) } +#[tokio::test(flavor = "multi_thread")] +async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let (a, b, models) = (tmp.path().join("a"), tmp.path().join("b"), tmp.path().join("models.json")); + std::fs::create_dir_all(a.join(".claude/skills/triage")).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(a.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n").unwrap(); + let names = |c: Vec| c.into_iter().map(|c| c.name).collect::>(); + + let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); + // Before any session: the skills on disk. + assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]); + let task = mgr_a + .create(NewAgentTask { + prompt: text("Fix the lexer"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr_a, &task.id, AgentStatus::NeedsInput).await; + assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["review", "triage"]); + assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]); + // Other agents get only what they offered. + assert!(mgr_a.commands("codex", None).await.unwrap().is_empty()); + mgr_a.shutdown(); + + // Another project shares the settings but not the commands. + let mgr_b = manager_at(&b, &models, Arc::new(FakeAgent::default())); + let claude = mgr_b.providers().into_iter().find(|p| p.id == "claude").unwrap(); + assert_eq!(claude.config.len(), 2); + assert!(claude.commands.is_empty()); + assert!(mgr_b.commands("claude", None).await.unwrap().is_empty()); + let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); + assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]); +} + async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { for _ in 0..500 { if mgr.task(id).unwrap().state.lock().task.status == status { @@ -299,6 +343,9 @@ fn prompts_become_acp_content_blocks() { blocks(&p, &[Some("fn a() {}".into())])[1], json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}}) ); + // A slash command goes through as typed: the agent runs it. + let skill = clean(text(" /triage dry-run\n")).unwrap(); + assert_eq!(blocks(&skill, &[]), [json!({"type": "text", "text": "/triage dry-run"})]); let old = upgrade( json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}), ); @@ -375,6 +422,7 @@ async fn real_claude_task_in_a_worktree() { worktrees_dir: tmp.path().join("wt"), models_file: tmp.path().join("models.json"), custom_agents_dir: None, + home_dir: None, }, tokio::runtime::Handle::current(), ); diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index ea34a02e..f04372c5 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,6 +1,6 @@ use crate::state::AppState; use ide_agent::{ - AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, + AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, }; use ide_core::{IdeResult, IpcPath}; use std::sync::Arc; @@ -27,6 +27,20 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult< Ok(state.project(window.label())?.agents.providers()) } +/// Slash commands to complete for `provider` in `task`'s directory, or in the project +/// before a task exists (skills and custom commands on disk included). +#[tauri::command] +#[specta::specta] +pub async fn agent_commands( + window: Window, + state: State<'_, AppState>, + provider: String, + task: Option, +) -> IdeResult> { + let project = state.project(window.label())?; + project.agents.commands(&provider, task.as_deref()).await +} + #[tauri::command] #[specta::specta] pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e55f4e98..09cec38c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -62,6 +62,7 @@ pub fn builder() -> tauri_specta::Builder { app::log_frontend, agent::agent_subscribe, agent::agent_providers, + agent::agent_commands, agent::agent_entries, agent::agent_create, agent::agent_prompt, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 0f2073ae..01460696 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -417,6 +417,7 @@ impl AppState { .join(&project_key), models_file: self.data_dir.join("agents").join("models.json"), custom_agents_dir: Some(self.config_dir.join("agents")), + home_dir: dirs::home_dir(), }, tauri::async_runtime::handle().inner().clone(), )); diff --git a/src/bindings.ts b/src/bindings.ts index 75150d95..36fee4c4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -56,6 +56,11 @@ export const commands = { /** Streams the project's agent events to this window; returns every task. */ agentSubscribe: (onEvent: Channel) => typedError(__TAURI_INVOKE("agent_subscribe", { onEvent })), agentProviders: () => typedError(__TAURI_INVOKE("agent_providers")), + /** + * Slash commands to complete for `provider` in `task`'s directory, or in the project + * before a task exists (skills and custom commands on disk included). + */ + agentCommands: (provider: string, task: string | null) => typedError(__TAURI_INVOKE("agent_commands", { provider, task })), agentEntries: (task: string) => typedError(__TAURI_INVOKE("agent_entries", { task })), agentCreate: (request: NewAgentTask) => typedError(__TAURI_INVOKE("agent_create", { request })), /** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */ diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index cf559e8b..598e4210 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -9,7 +9,7 @@ import { Composer } from "./Composer"; import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { providerIcon } from "./model"; -import { HOME, startTask, updateDraft, useAgents } from "./store"; +import { HOME, NO_COMMANDS, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; import styles from "./Home.module.css"; export function HomeEditor() { @@ -36,6 +36,7 @@ export function HomeEditor() { }, []); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; + const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); // Installed agents first; the rest under a "Not installed" title, with the command they need. const installed = providers.filter((p) => p.installed); const missing = providers.filter((p) => !p.installed); @@ -70,7 +71,7 @@ export function HomeEditor() { placeholder="Describe a task for the agent. @ references a file, / runs a command" root={root} projectRoot={root} - commands={provider?.commands ?? []} + commands={agentCommands} history={history} config={provider?.config ?? []} onConfig={setOption} diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 4c7b78b5..58848414 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -25,6 +25,7 @@ import { sendMessage, sendQueuedNow, setTaskConfig, + useAgentCommands, useAgents, } from "./store"; import styles from "./Session.module.css"; @@ -279,6 +280,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt const project = useProject((s) => s.project); const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft(); const [sending, setSending] = useState(false); + const agentCommands = useAgentCommands(task.provider, task.id, task.commands); const busy = isBusy(task.status); // ↑ recalls this conversation's messages, newest first. const history = useMemo( @@ -304,7 +306,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"} root={task.cwd} projectRoot={project?.root ?? task.cwd} - commands={task.commands} + commands={agentCommands} history={history} config={task.config} onConfig={setOption} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index 5c7d1160..0718f5e8 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -66,6 +66,21 @@ describe("agent composer", () => { expect(matchCommands(cmds, "rev").map((c) => c.name)).toEqual(["review", "pr-review"]); }); + it("matches commands by prefix, plugin name, substring, then letters in order", () => { + const cmds = ["triage", "verify", "issue-fix", "brooks-lint:brooks-review", "pr-review", "init"].map((name) => ({ + name, + description: "", + hint: null, + })); + const names = (q: string) => matchCommands(cmds, q).map((c) => c.name); + expect(names("trig")).toEqual(["triage"]); + expect(names("brooks-r")).toEqual(["brooks-lint:brooks-review"]); + expect(names("rev")).toEqual(["brooks-lint:brooks-review", "pr-review"]); + expect(names("if")).toEqual(["verify", "issue-fix"]); + expect(names("xyz")).toEqual([]); + expect(names("")).toHaveLength(cmds.length); + }); + it("sends only the files still mentioned", () => { const prompt = toPrompt({ text: " look at @src/a.rs:3-5 and @b.rs ", diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx new file mode 100644 index 00000000..2eda9eb0 --- /dev/null +++ b/src/features/agents/composerCommands.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { AgentCommand, AgentProvider } from "@/bindings"; + +const agentCommands = vi.fn(); +const agentCreate = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + agentCommands: (provider: string, task: string | null) => agentCommands(provider, task), + agentCreate: (request: unknown) => agentCreate(request), + vcsBranches: () => Promise.resolve({ status: "ok", data: [] }), + }, + }; +}); +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }), +})); + +import { HomeEditor } from "./HomeEditor"; +import { HOME, updateComposer, useAgents } from "./store"; +import { emptyDraft } from "./composerModel"; + +const command = (name: string, description: string): AgentCommand => ({ name, description, hint: null }); +const claude: AgentProvider = { + id: "claude", + name: "Claude Code", + command: "npx", + installed: true, + config: [], + // Offered by the agent's last session in this project. + commands: [command("compact", "Free up context")], +}; + +beforeEach(() => { + agentCommands.mockReset(); + agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } }); + useAgents.setState({ + providers: [claude], + draft: { provider: "claude", config: {}, isolation: "local", base: null }, + }); + updateComposer(HOME, emptyDraft()); +}); + +describe("New Task composer commands", () => { + it("completes the project's skills merged with the agent's commands", async () => { + agentCommands.mockResolvedValue({ + status: "ok", + data: [ + command("compact", "Free up context"), + command("triage", "One pass over open issues and PRs"), + command("issue-fix", "Fix an issue"), + ], + }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null)); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "/trig" } }); + const list = await screen.findByRole("listbox", { name: "Commands" }); + const options = within(list).getAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]); + + fireEvent.keyDown(box, { key: "Enter" }); + expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage "); + + // Sent as typed, so the agent runs the skill. + fireEvent.change(box, { target: { value: "/triage dry-run" } }); + fireEvent.keyDown(box, { key: "Enter" }); + await waitFor(() => expect(agentCreate).toHaveBeenCalled()); + expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" }); + }); + + it("offers nothing the project does not have", async () => { + agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalled()); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "/trig" } }); + expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull(); + fireEvent.change(box, { target: { value: "/com" } }); + expect(await screen.findByRole("option")).toHaveTextContent("/compact"); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index ca21d89c..0e02294a 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -40,11 +40,29 @@ export function applyCompletion(text: string, c: Completion, insert: string): { return { text: next, caret: c.from + insert.length + 1 }; } -/** Commands matching `query`, prefix matches first. */ +/** + * Commands matching `query`: prefix matches first (a plugin's "plugin:name" also by its + * name), then substrings, then names holding its letters in order ("trig" → "triage"). + */ export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] { const q = query.toLowerCase(); - const hits = commands.filter((c) => c.name.toLowerCase().includes(q)); - return hits.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q))); + const rank = (name: string) => { + const n = name.toLowerCase(); + if (n.startsWith(q) || n.slice(n.lastIndexOf(":") + 1).startsWith(q)) return 0; + if (n.includes(q)) return 1; + return inOrder(n, q) ? 2 : -1; + }; + return commands + .map((command) => ({ command, rank: rank(command.name) })) + .filter((m) => m.rank >= 0) + .sort((a, b) => a.rank - b.rank) + .map((m) => m.command); +} + +function inOrder(text: string, query: string): boolean { + let i = 0; + for (const ch of text) if (ch === query[i]) i++; + return i >= query.length; } /** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */ diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index b064a771..9606211b 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -1,7 +1,16 @@ /** Agent tasks of the open project, mirrored from `ide-agent` through `agentSubscribe`. */ import { Channel } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; import { create } from "zustand"; -import type { AgentChange, AgentEntry, AgentEvent, AgentProvider, AgentTask, Isolation } from "@/bindings"; +import type { + AgentChange, + AgentCommand, + AgentEntry, + AgentEvent, + AgentProvider, + AgentTask, + Isolation, +} from "@/bindings"; import { call, commands, isTauri } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel"; @@ -212,6 +221,34 @@ async function refreshProviders() { if (providers) useAgents.setState({ providers }); } +export const NO_COMMANDS: readonly AgentCommand[] = []; + +/** + * Slash commands to complete: what the agent offered (`offered`; fetched again when it + * changes) plus, for Claude Code, the skills and custom commands on disk. Without `task`, + * those of the project. + */ +export function useAgentCommands( + provider: string | undefined, + task: string | null, + offered: readonly AgentCommand[], +): readonly AgentCommand[] { + const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null); + useEffect(() => { + if (!provider || !isTauri()) return; + let alive = true; + call(commands.agentCommands(provider, task)) + .then((list) => alive && setFound({ for: [provider, task, offered], list })) + .catch(() => {}); + return () => { + alive = false; + }; + }, [provider, task, offered]); + // Until the list for these arguments arrives, what the agent offered. + const current = found && found.for[0] === provider && found.for[1] === task && found.for[2] === offered; + return current ? found.list : offered; +} + export function openTask(task: string) { // The title rides along so a restored tab has it before the tasks load. const title = useAgents.getState().tasks.get(task)?.title;