Palettes
The palette is the TUI's interaction model — filterable, tabbed, keyboard-driven, fast. Every built-in mode is one. You can build your own, and you get the same machinery: the filtering, the cursor, the tab strip, the scrolling, all of it.
That is what makes this the most useful thing in the extension API. A palette plus a key chord is a complete workflow, and it looks and behaves exactly like the interface it sits inside.
Creating one
palette.create("branches", {
key: "&",
description: "switch branch",
async list(query, tab) {
const branches = await readBranches()
return branches.map(branch => ({
id: branch.sha,
label: branch.name,
description: branch.subject,
action: () => {
tui.info(`checked out ${branch.name}`)
},
}))
},
})
Three ways to open it, all equivalent:
await palette.open("branches") // from code
keys.register("ctrl+b", () => palette.open("branches"))
...or press &, the mode key it claimed. It behaves exactly like a built-in mode key:
only from an empty input, and pressing it again toggles back.
The list
list() is called with the current query and active tab, and may be async. While the
first call is in flight the palette shows its own loading row, then swaps in your rows
when it settles. Results are cached per query and tab.
You do not have to filter — Axon applies its own substring match over labels and
descriptions. Return everything and let the palette narrow it. Set filter: false if
your list() already narrows by query itself.
Rows
| Field | What it does |
|---|---|
id | Stable identity for the row |
label | What is shown |
description | Dim text beside the label |
action | Run on Enter |
preview | Run as the cursor moves over it |
header | A non-selectable section title |
separator | A blank spacer between groups |
Headers and separators are structure, not results: a header only survives filtering when something under it matched, and a header that matches the query pulls its whole group in.
Tabs
palette.create("work", {
tabs: [
{ id: "open", label: "Open" },
{ id: "done", label: "Done" },
],
async list(query, tab) {
return tab === "done" ? closedItems() : openItems()
},
})
← and → switch tabs. Your list() is called again with the new tab.
Refreshing
const branches = palette.create("branches", { /* ... */ })
// later — the underlying data changed
branches.refresh()
refresh() drops the cache so the next open recomputes. It does not reopen anything.
Asking a question
Three verbs for when your code needs an answer:
const branch = await palette.pick(["main", "dev", "staging"])
const sure = await palette.confirm("Deploy to production?")
const name = await palette.prompt("Agent name?")
They return promises, and that is the entire design. A multi-step flow is sequential awaits — there is no wizard framework, because there does not need to be one:
commands.register("deploy", async () => {
const env = await palette.pick(["staging", "production"])
if (!env) return
const sure = await palette.confirm(`Deploy to ${env}?`)
if (!sure) return
await runDeploy(env)
tui.info(`deployed to ${env}`)
})
Escape is an answer
Every question resolves when the user walks away — undefined for pick and prompt,
false for confirm. It is in the return type, so you cannot forget to handle it. A
cancelled question is an ordinary outcome, not an error.
Picking objects
pick over strings returns the string. Pick over objects and you get the object back,
not the label:
const branch = await palette.pick([
{ label: "main", description: "the trunk", value: { sha: "aaa1111" } },
{ label: "dev", description: "in progress", value: { sha: "bbb2222" } },
])
if (branch) tui.info(branch.sha)
One at a time
Opening a question or a palette while one is already open throws. Stealing the palette out from under someone mid-navigation drops whatever they were answering, and queueing would let a config stack modal prompts in front of them. Check first if a keybind might collide:
if (palette.isOpen) return
Reference
palette.create(name, definition) // → handle { open, refresh, dispose }
palette.get(name) // → handle | null
palette.open(name) // → Promise<void>
palette.close()
palette.isOpen // → boolean
palette.pick(options, opts?) // → Promise<T | undefined>
palette.confirm(message) // → Promise<boolean>
palette.prompt(message, opts?) // → Promise<string | undefined>
What's next
Plugins & Hooks — running code when something happens, rather than when someone asks.