commands
Commands in the : tree. Built-ins always win a collision; between two extensions the
first loaded keeps the path and the second throws.
interface CommandsApi {
// Add a command. Two forms — a bare function, or a definition object.
register(
path: string | readonly string[],
run: (signal: AbortSignal) => void | Promise<void>
): Disposer
register(
path: string | readonly string[],
definition: {
run: (signal: AbortSignal) => void | Promise<void>
description?: string // shown beside the command in the palette
workingMessage?: string // shown in the working row while it runs
}
): Disposer
// Run one by path, exactly as pressing Enter on it would.
// Throws COMMAND_NOT_FOUND on a missing path, or on a group.
run(path: string | readonly string[]): Promise<void>
// Every registered path, built-in and user. Dynamic groups are not expanded.
list(): readonly string[][]
}
Two forms
// Nothing to configure — just the behaviour.
commands.register("hello", () => tui.info("hi"))
// With presentation.
commands.register("sync", {
async run(signal) {
await longRunningThing({ signal })
},
description: "Sync everything",
workingMessage: "syncing...",
})
Both reach the tree identically. Returning a promise holds the palette in a working row
until it settles; the signal aborts when the user presses Escape mid-flight.
Nested paths
commands.register(["git", "push"], run)
commands.register(["git", "pull"], run)
Commands sharing a prefix share a group, created on demand — :git now descends into
one with both. There is nothing to declare.
Reaching what has no API
commands.register("fresh", async () => {
await commands.run("agent clear")
input.set("let's start over")
})
One verb, instead of re-exposing every command's implementation on the surface.
Errors
commands.register("reload", run) // COMMAND_PATH_TAKEN — built-in
commands.register("x", { description: "hi" }) // COMMAND_INVALID — no run
await commands.run("git") // COMMAND_NOT_FOUND — a group
COMMAND_INVALID fires at registration, not at press time — a command that registers
cleanly and does nothing on Enter reads as a broken terminal rather than a config mistake.
Dynamic groups are not expanded
commands.list()
// [..., ["open"], ["module", "update"], ...]
// ^ the group, not its rows
:open asks whether an editor is attached; :module update asks the registry what is
published. Expanding those in a read would boot composables as a side effect and return
rows true only for that instant. Reach one with run().