Commands & Keys
Two ways to reach something you built: type it in the : palette, or press a key.
Commands
A command is a name, an action, and a description.
commands.register("deploy", async () => {
await commands.run("agent restart")
}, { description: "Restart and redeploy" })
It appears in the : palette beside the built-ins — filterable, tab-completable, with
your description shown next to it.
Nested paths
Pass an array to nest. Commands sharing a prefix share the group automatically:
commands.register(["git", "push"], async () => { /* ... */ })
commands.register(["git", "pull"], async () => { /* ... */ })
:git now descends into a group with both. That is the same mechanism the built-in
:provider and :open groups use — there is nothing to declare, the group exists
because two paths share a segment.
Long-running commands
Return a promise and the palette holds a working row until it settles.
commands.register("sync", async signal => {
await longRunningThing({ signal })
}, {
description: "Sync everything",
workingMessage: "syncing...",
})
The signal aborts when the user presses Escape mid-flight. An action that cannot be
cancelled can ignore it; one that can should honour it.
Running a command from code
await commands.run("clear")
await commands.run(["agent", "restart"])
This reaches any command, including built-ins. It is the shortest path to
functionality that has a command but no dedicated API — rather than re-implementing what
:clear does, run :clear.
Keys
keys.register("ctrl+p", async () => {
await palette.open("files")
})
Chords are written the way you read them everywhere else: ctrl+o, ctrl+shift+p,
shift+tab, f5.
Handlers are fire-and-forget — nothing waits on your code while a key is held. If yours throws, the chord and the failure flash on the status row rather than disappearing silently.
Keys Axon keeps
Some chords belong to the terminal and cannot be rebound:
| Reserved | Why |
|---|---|
ctrl+c, ctrl+d | Quit — a config must never be able to make Axon unquittable |
escape | Cancel, and back out of any mode |
enter, tab | Submit and complete |
arrow keys, backspace, delete | Palette navigation and editing |
: ~ / ^ * % # ? > ! | Built-in mode keys |
Registering one of these throws immediately, naming the chord. It fails at registration rather than silently not firing — a binding that does nothing is indistinguishable from one that was never written, and that is a miserable thing to debug in your own config.
Sending a key
keys.send("ctrl+o")
Delivered as though you had pressed it. This is the escape hatch: anything the API does not expose directly is still reachable, because everything in the TUI is reachable from the keyboard.
When two things want the same name
Built-ins always win. Registering :clear or ctrl+c throws — the documented
surface cannot be replaced from a config file.
Between your own registrations, the first one wins, and load order decides which is first:
main.ts (and anything it imports)
→ plugins/ alphabetically
→ each extension, in profile.config.ts order
Your own config loads first, so it beats every extension. Between two extensions, the
one listed earlier in profile.config.ts wins.
A collision throws, naming both sides. Nothing is ever silently shadowed — "which of my
two extensions bound ctrl+o" should never be a question you answer by bisecting your
config.
Reference
commands.register(path, action, options?) // → Disposer
commands.run(path) // → Promise<void>
commands.list() // → string[][]
keys.register(chord, handler) // → Disposer
keys.send(chord)
Every register returns a function that undoes it. You rarely need it — Axon disposes
everything your config registered before a reload — but it is there when you want to
unbind something conditionally.
What's next
Palettes — your own lists, and asking the user a question.