7.5 KiB
Slash Command Subcommand Patterns
Pi does not have a separate built-in concept of "nested slash commands" like /wt new or /foo delete.
Instead, this UX is built by registering a single slash command and using argument completions to make the first argument behave like a subcommand.
This is the pattern used by the built-in worktree extension:
/wt/wt new/wt ls/wt switch my-branch
The key API is:
pi.registerCommand(name, options)getArgumentCompletions(prefix)handler(args, ctx)
Mental Model
Treat the command as:
- one top-level slash command
- one or more positional arguments
- the first positional argument acting as a subcommand
- optional later arguments completed dynamically based on the first
So this:
/wt
new
ls
switch
merge
rm
status
is really just:
- command:
wt - first arg: one of
new | ls | switch | merge | rm | status
The Core Pattern
pi.registerCommand("foo", {
description: "Manage foo items: /foo new|list|delete [name]",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["new", "list", "delete"];
const parts = prefix.trim().split(/\s+/);
// Complete the first argument: /foo <subcommand>
if (parts.length <= 1) {
return subcommands
.filter((cmd) => cmd.startsWith(parts[0] ?? ""))
.map((cmd) => ({ value: cmd, label: cmd }));
}
// Complete the second argument: /foo delete <name>
if (parts[0] === "delete") {
const items = ["alpha", "beta", "gamma"];
const namePrefix = parts[1] ?? "";
return items
.filter((name) => name.startsWith(namePrefix))
.map((name) => ({ value: `delete ${name}`, label: name }));
}
return [];
},
handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/);
const sub = parts[0];
const name = parts[1];
await ctx.waitForIdle();
if (sub === "new") {
ctx.ui.notify("Create a new foo item", "info");
return;
}
if (sub === "list") {
ctx.ui.notify("List foo items", "info");
return;
}
if (sub === "delete") {
if (!name) {
ctx.ui.notify("Usage: /foo delete <name>", "error");
return;
}
ctx.ui.notify(`Deleting ${name}`, "info");
return;
}
ctx.ui.notify("Usage: /foo <new|list|delete> [name]", "info");
},
});
How getArgumentCompletions() Behaves
getArgumentCompletions(prefix) receives everything after the slash command name.
Examples for /foo:
- typing
/foogivesprefix === "" - typing
/foo degivesprefix === "de" - typing
/foo delete agivesprefix === "delete a"
That means you can parse the prefix into words and decide what suggestions to show next.
A common structure is:
- If the user is on the first argument, show available subcommands.
- If the first argument selects a branch like
delete, show completions for the next argument. - Otherwise return
[].
Important Detail: Empty Prefix Handling
A practical gotcha is that:
"".trim().split(/\s+/)
produces [''], not [].
That is why the common pattern is:
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
// complete first argument
}
This handles both:
- completely empty input after the command
- partially typed first arguments
Dynamic Second-Argument Completion
This pattern becomes powerful when later arguments depend on the subcommand.
Example:
getArgumentCompletions: (prefix) => {
const parts = prefix.trim().split(/\s+/);
const sub = parts[0];
if (parts.length <= 1) {
return ["new", "list", "delete"].map((s) => ({ value: s, label: s }));
}
if (sub === "delete") {
const items = getCurrentItemsSomehow();
const namePrefix = parts[1] ?? "";
return items
.filter((item) => item.startsWith(namePrefix))
.map((item) => ({ value: `delete ${item}`, label: item }));
}
return [];
}
This is how /wt switch, /wt merge, and /wt rm can suggest current worktree names.
Real Example: /wt
The worktree extension uses this exact structure in:
/Users/lexchristopherson/.gsd/agent/extensions/worktree/index.ts
It defines:
const subcommands = ["new", "ls", "switch", "merge", "rm", "status"];
Then:
- when the first argument is still being typed, it suggests those subcommands
- when the first argument is
switch,merge, orrm, it suggests matching worktree names for the second argument
That is why typing:
/wt
shows:
new
ls
switch
merge
rm
status
and typing:
/wt switch
shows available worktree names.
Parsing in the Handler
Your completion logic and your handler logic should agree on the command shape.
A common structure is:
handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/);
const sub = parts[0];
const rest = parts.slice(1);
switch (sub) {
case "new":
// handle /foo new
return;
case "list":
// handle /foo list
return;
case "delete":
// handle /foo delete <name>
return;
default:
ctx.ui.notify("Usage: /foo <new|list|delete>", "info");
return;
}
}
Keep the parsing simple and mirror the same branches your completions advertise.
When to Use This Pattern
Use a single command with subcommand-style completions when:
- the actions belong to one clear domain
- you want discoverability from one entry point
- the subcommands feel like one family of operations
- later arguments depend on the earlier choice
Examples:
/wt new|switch|merge|rm|status/preset save|load|delete/workflow start|list|abort/foo new|list|delete
When to Prefer Separate Commands
Prefer separate commands when:
- the actions are conceptually unrelated
- each command needs its own distinct description and identity
- autocomplete would become too deep or overloaded
- the combined command would become hard to remember or document
Good candidates for separate commands:
/deploy/rollback/handoff
rather than forcing all of those into one umbrella command.
UX Guidelines
A few practical rules make this pattern feel good:
- Keep top-level subcommands short and obvious.
- Use names that read naturally after the slash command.
- Keep branching shallow; one or two levels is usually enough.
- Return an empty array when no completion makes sense.
- Make your fallback usage text match your completion structure.
- If a subcommand needs required data, validate it again in the handler.
Recommended Structure
A solid command with subcommands usually has:
descriptionshowing the top-level grammargetArgumentCompletions()for first and second argument suggestionshandler()that branches on the first argument- a fallback usage message for invalid input
Example description:
description: "Manage foo items: /foo new|list|delete [name]"
Related Docs
Read these alongside this pattern:
/Users/lexchristopherson/.gsd/docs/extending-pi/11-custom-commands-user-facing-actions.md/Users/lexchristopherson/.gsd/docs/extending-pi/09-extensionapi-what-you-can-do.md/Users/lexchristopherson/.gsd/agent/extensions/worktree/index.ts
Summary
If you want /foo to behave like it has nested subcommands, do this:
- register one slash command
- treat the first argument as a subcommand
- implement
getArgumentCompletions(prefix) - optionally complete later arguments dynamically
- branch in the handler based on the parsed first argument
That is the mechanism behind the /wt experience.