palette
The TUI's interaction primitive: your own lists, and asking the user a question. A palette
you register is the same widget as : — identical row shapes, filtering and
navigation — so every extension's UI is as good as the built-in one.
interface PaletteApi {
// Your own palettes.
create(name: string, definition: PaletteDefinition): PaletteHandle
get(name: string): PaletteHandle | null
open(name: string): Promise<void> // throws if one is ALREADY open
close(): void
readonly isOpen: boolean // real lists only — not loading/voice
// Asking the user. All three resolve to a cancelled value on escape.
pick(options: readonly string[], opts?: { placeholder?: string }): Promise<string | undefined>
pick<T>(options: readonly PickOption<T>[], opts?: { placeholder?: string }): Promise<T | undefined>
confirm(message: string): Promise<boolean>
prompt(message: string, opts?: { placeholder?: string; initial?: string }): Promise<string | undefined>
}
type PaletteDefinition = {
list: (query: string, tab: string | null) =>
PaletteItem[] | PaletteResult | Promise<PaletteItem[] | PaletteResult>
key?: string // mode key that opens it from an empty input
filter?: boolean // true when you filter by query yourself
tabs?: PaletteTab[]
maxHeight?: number
anchor?: "top" | "bottom" // "bottom" = terminal-log feel
}
type PaletteItem = {
id: string
label: string
description?: string
action?: (signal: AbortSignal) => void | Promise<void>
chunks?: readonly string[] // independently searchable parts
descendQuery?: string // rewrite the query instead of running
choices?: () => PaletteItem[] // a follow-up list
preview?: () => void // run on cursor movement
workingMessage?: string
detach?: boolean // close now, finish in the background
header?: boolean
separator?: boolean
}
type PaletteResult = {
items: PaletteItem[]
breadcrumb?: string | null
status?: PaletteStatus
invalid?: boolean // the query cannot resolve to anything selectable
}
type PaletteHandle = {
readonly name: string
open: () => Promise<void>
refresh: () => void // drop the cached list; does not reopen
dispose: Disposer
}
type PickOption<T> = { label: string; description?: string; value: T }
A palette of your own
palette.create("branches", {
key: "&",
async list() {
return (await gitBranches()).map(name => ({
id: name,
label: name,
action: () => checkout(name),
}))
},
})
The TUI filters for you, so a list that ignores query is still searchable. Set
filter: true when you filter yourself.
Searchable rows
{ id, label: "claude-sonnet-5", chunks: ["openrouter", "anthropic", "claude-sonnet-5"] }
Every query term narrows against any chunk — how ~400 models live in one flat list.
Asking the user
commands.register("deploy", async () => {
const env = await palette.pick(["staging", "production"])
if (!env) return
if (!await palette.confirm(`Deploy to ${env}?`)) return
const tag = await palette.prompt("Tag", { initial: "latest" })
if (!tag) return
await runDeploy(env, tag)
})
Sequential awaits. There is no wizard framework because there does not need to be one —
each step resolves to a cancelled value on escape, so if (!x) return is the whole
control flow.
pick also takes objects when the choice is not a string:
const target = await palette.pick([
{ label: "main", description: "default", value: mainRef },
{ label: "dev", value: devRef },
]) // resolves to the ref, not the label
isOpen
mode.set("command") // isOpen === true
mode.set("loading") // isOpen === false — a spinner, not a list
A palette is a list of rows to pick from. loading, voice and escalation are input
states, and nothing is being picked in any of them.
open() throws if another palette is already up — stealing it mid-navigation is the kind
of silent degradation that makes an extension system feel haunted. Check isOpen first
if a keybind might collide.