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:
l0ng-ai
2026-10-27 20:59:52 +08:00
parent 8568ff001b
commit 34828bd710
13 changed files with 576 additions and 32 deletions
+56 -23
View File
@@ -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(),
};
+281
View File
@@ -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);
}
}
+49 -1
View File
@@ -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(),
);
+15 -1
View File
@@ -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>> {
+1
View File
@@ -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,
+1
View File
@@ -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(),
));
+5
View File
@@ -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. */
+3 -2
View File
@@ -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}
+3 -1
View File
@@ -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}
+15
View File
@@ -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");
});
});
+21 -3
View File
@@ -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"). */
+38 -1
View File
@@ -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;