mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(agents): Complete project skills and custom commands in the agent composer
The `/` completion of the New Task page only listed the commands the agent offered in its last session, cached once for all projects, and matched them by substring. Before a project's first session, or after a session in another project, its skills were missing, and `/trig` never matched `/triage`. - Remember the commands an agent offered per project root (settings stay shared). - New `agent_commands` command: the agent's last list for the project (or the task's own list in a session) merged with, for Claude Code, the skills (`.claude/skills/*/SKILL.md`) and custom commands (`.claude/commands/**/*.md`, nested as `dir:name`) found from the working directory upward and in `~/.claude`. The agent's entries win on a name; skills with `user-invocable: false` are left out. Discovery runs off the command thread. - Sessions discover from the task's own directory (worktree or checkout). - Command matching ranks prefix, plugin-name prefix, substring, then letters in order. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
+56
-23
@@ -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 `<id>.toml` each (`name`, `command`, `args`, `env`).
|
||||
pub custom_agents_dir: Option<PathBuf>,
|
||||
/// The user's home: its `.claude` holds their own skills and commands.
|
||||
pub home_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<AgentConfigOption>,
|
||||
#[serde(default)]
|
||||
commands: Vec<AgentCommand>,
|
||||
/// By project root: skills and custom commands differ per project.
|
||||
#[serde(default, rename = "projectCommands")]
|
||||
commands: HashMap<String, Vec<AgentCommand>>,
|
||||
}
|
||||
|
||||
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<AgentConfigOption> {
|
||||
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<AgentCommand> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<Vec<AgentCommand>> {
|
||||
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<Arc<Task>> {
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Claude Code's slash commands on disk, for completion before (or without) a session:
|
||||
//! skills (`.claude/skills/<dir>/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<AgentCommand> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut add = |found: Vec<AgentCommand>| {
|
||||
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<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = 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<AgentCommand> {
|
||||
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<AgentCommand> {
|
||||
let mut out: Vec<AgentCommand> = 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<AgentCommand>) {
|
||||
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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
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: <pr>\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("<pr>"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,10 @@ impl Handler for FakeAgent {
|
||||
}
|
||||
|
||||
fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
manager_at(dir, &dir.join("models.json"), agent)
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> 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<FakeAgent>) -> 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<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
@@ -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<String>,
|
||||
) -> IdeResult<Vec<AgentCommand>> {
|
||||
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<Vec<AgentEntry>> {
|
||||
|
||||
@@ -62,6 +62,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::log_frontend,
|
||||
agent::agent_subscribe,
|
||||
agent::agent_providers,
|
||||
agent::agent_commands,
|
||||
agent::agent_entries,
|
||||
agent::agent_create,
|
||||
agent::agent_prompt,
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -56,6 +56,11 @@ export const commands = {
|
||||
/** Streams the project's agent events to this window; returns every task. */
|
||||
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
|
||||
agentProviders: () => typedError<AgentProvider[], IdeError>(__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<AgentCommand[], IdeError>(__TAURI_INVOKE("agent_commands", { provider, task })),
|
||||
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
|
||||
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
|
||||
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(<HomeEditor />);
|
||||
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(<HomeEditor />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user