singularity-forge/docs/dev/extending-pi/25-slash-command-subcommand-patterns.md
ace-pm b29c12d5e5 refactor(native): rename gsd_parser.rs to forge_parser.rs
Final rebrand: rename remaining Rust source file to complete the gsd → forge
transition. All parser references already use forge_parser after earlier commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:58:21 +02:00

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 /foo gives prefix === ""
  • typing /foo de gives prefix === "de"
  • typing /foo delete a gives prefix === "delete a"

That means you can parse the prefix into words and decide what suggestions to show next.

A common structure is:

  1. If the user is on the first argument, show available subcommands.
  2. If the first argument selects a branch like delete, show completions for the next argument.
  3. 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/.sf/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, or rm, 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.

A solid command with subcommands usually has:

  • description showing the top-level grammar
  • getArgumentCompletions() for first and second argument suggestions
  • handler() that branches on the first argument
  • a fallback usage message for invalid input

Example description:

description: "Manage foo items: /foo new|list|delete [name]"

Read these alongside this pattern:

  • /Users/lexchristopherson/.sf/docs/extending-pi/11-custom-commands-user-facing-actions.md
  • /Users/lexchristopherson/.sf/docs/extending-pi/09-extensionapi-what-you-can-do.md
  • /Users/lexchristopherson/.sf/agent/extensions/worktree/index.ts

Summary

If you want /foo to behave like it has nested subcommands, do this:

  1. register one slash command
  2. treat the first argument as a subcommand
  3. implement getArgumentCompletions(prefix)
  4. optionally complete later arguments dynamically
  5. branch in the handler based on the parsed first argument

That is the mechanism behind the /wt experience.