Documentation
¶
Overview ¶
Package registry is the one command registry established by the August 2026 chat-rebuild audit's 5.22 (no longer in the tree; "Discoverability: no typed-only actions"): every action lives on a visible object, and typing is an accelerator, never the only door. A single catalog of entries — id, verb phrase, description, scope predicate, key binding, slash alias, journal mapping — is the source every render surface (action strips, the summon palette, slash-filtered palette, contextual footer, chips, empty states) reads from, so discoverability holds by construction instead of by six surfaces staying in sync by hand.
This package is pure data and query functions. It renders nothing and knows nothing about Bubble Tea, lipgloss, or any TUI type — the dependency runs one way, tui → registry, so a future web surface can read the same catalog without dragging a terminal renderer in behind it.
Wave 1 seeds the catalog faithfully from what already exists — the slash table, the chat/task-page keybindings, and the head belt verbs that journal a real command — and adds no entry a live surface cannot already reach some other way. UI adoption (wiring the six surfaces to read from here instead of their own tables) is Waves 2–3.
Index ¶
Constants ¶
const AskKey = "ask"
AskKey is what a surface may put in the key half of a verb whose only door is prose — a ScopeTalk entry, reached by asking. It is offered rather than applied by ChipOn: a footer with a key column drops such a row, and a palette that lists every capability prints the word, and both are right for their own surface. Printing nothing in a list that teaches doors would read as "no way to do this", which is the opposite of true.
const ChipGap = " "
ChipGap is the single space between the two halves. It is named because the painters draw the halves in separate calls and a gap that lived in each of them would be a gap that could differ between them.
const ScopeAny = ScopeThread | ScopeNode | ScopeTalk
ScopeAny matches every entry a surface can offer with nothing in particular selected. It is the predicate a surface with no current focus (an empty state, the unscoped summon palette) queries with.
It deliberately leaves ScopeItem out — see that scope's own doc. A caller that genuinely wants the whole catalog, item verbs included (a completeness test, a capability inventory), asks with ScopeEvery.
const ScopeEvery = ScopeAny | ScopeItem
ScopeEvery is every scope this package defines. It is the honest spelling of "the whole catalog" for the handful of callers that mean it.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Chip ¶
type Chip struct {
// Verb is the action word. It is drawn FIRST, at the brighter of the
// surface's two tiers, and it is never dropped: a chip that cut its verb to
// spell its key would be teaching a keystroke with no name on it.
Verb string
// Key is the accelerator, drawn after the verb and one tier down. EMPTY IS
// A REAL ANSWER, not a gap — a belt-only verb is reached through the user's
// own words and has no key by construction — so a painter must test it
// rather than assume the chip has two halves.
Key string
}
Chip is one action word and the accelerator that reaches it, in the order every surface draws them.
The whole chip is the click target — a reader points at the words, not at the key — but only the verb is drawn at the tier that says so.
func ChipFor ¶
ChipFor is the chip for an act that is not a catalog row: an overlay's own exit, a dialog's confirm. Those are real verb·key pairs on real surfaces and they obey the same grammar — the rule is about how a reader parses two words, not about where the words came from — so they are built as the same type rather than assembled by hand at each call site, which is how `esc close` got written three times in the first place.
func ChipOn ¶
ChipOn is the chip for a registry entry as one surface can honestly draw it.
The key half is Entry.KeyOn — so a bare letter resolves to nothing on a composer-first surface, where that letter is draft text — then the slash alias, then nothing. A caller that wants the prose door named in the empty case fills it with AskKey itself.
type Entry ¶
type Entry struct {
// ID is unique across the whole catalog, checked by TestEntriesHaveUniqueIDs.
ID string
// Verb is the short verb phrase a strip or chip renders: "cancel",
// "restart", "open self".
Verb string
// Description is the one-line sentence the palette and the `?` surface
// show beside Verb.
Description string
// Scope is where the entry applies — see Scope's doc.
Scope Scope
// Key is the canonical live key binding, in Bubble Tea's own chord
// spelling ("c", "alt+g", "ctrl+j"). Empty when the entry has none.
// Where today's surface accepts more than one spelling for the same
// chord (ctrl+t and alt+g both toggle the task list), Key names the one
// the help screen leads with; the synonym is not a second registration.
Key string
// ChordKey is this entry's accelerator on a [SurfaceComposerFirst]
// surface, where Key's bare letter cannot be bound at all. It is recorded
// only when a surface really binds the chord — the registry names doors
// that exist, never doors it would like to exist — so most rows leave it
// empty, and a row whose Key is already a chord never needs one.
//
// Read it through [Entry.KeyOn] or [Entry.On] rather than directly: those
// answer the question a render surface actually has, which is "what, if
// anything, do I tell the user to press here."
ChordKey string
// Slash is the alias typed after "/" in the composer or a task's steer
// line, without the leading slash. Empty when the entry has none.
Slash string
// Journal is what the action journals, when it journals anything.
Journal Journal
// Confirm is the question a destructive verb asks before it fires, in the
// product's own voice and in one line. EMPTY IS THE COMMON CASE and means
// the verb fires at once: pausing a rule, restarting a service and running
// a way of working are all reversible by doing the opposite, and asking
// about them would be ceremony.
//
// It is a sentence rather than a boolean because the only useful part of a
// confirmation is what it says is about to be lost, and that is per-verb:
// "retire this rule?" and "stop this service?" are not the same warning
// with a different noun in it. A surface draws it however it draws
// questions; this package renders nothing.
//
// Confirm and [Entry.Steer] are never both set. A verb that needs the
// user's own words is answered by them typing, and a confirmation on top of
// that would be asking twice about one sentence.
Confirm string
// Steer is the composer seed for a verb that carries an argument the
// resident has to interpret — a new cadence, a corrected belief, the reason
// a version is going back. Per the one-mouth law those verbs do not fire on
// a click: they put the user's cursor in the composer with the sentence
// half written, and the head reads what they finish.
//
// It carries exactly one %s, which is the item's own name as the user knows
// it. Read it through [Entry.SteerFor] rather than formatting it by hand.
Steer string
// contains filtered or unexported fields
}
Entry is one row of the registry: everything a render surface needs to show the action and everything a query needs to find it. The lowercase fields are precomputed once at package init so a fuzzy match over the whole catalog never lowercases the same string twice per keystroke.
func AppendScope ¶
AppendScope appends every entry whose scope overlaps scope onto dst and returns the extended slice — the append-into-caller-buffer idiom the rest of the tree already uses for per-frame lists (see internal/tui/node.go's appendTraceBlocks), so a render surface that keeps its own buffer across frames pays no allocation once it has grown to size.
func ByID ¶
ByID finds the one entry with this id. O(entries) worst case, no allocation — a linear scan over static data, which is cheap enough at this catalog's size that an index would cost more to keep correct than it saves.
func ByKey ¶
ByKey finds the live entry bound to key within scope. Two entries may share a Key across disjoint scopes (c means nothing in ScopeThread, cancel in ScopeNode) — ByKey resolves that the same way the surface does, by asking for the scope it is currently in.
func ByKeyOn ¶
ByKeyOn is ByKey asked from a surface: it finds the entry that key actually triggers in scope on that surface, matching against Entry.KeyOn rather than the raw Key field. A composer-first surface routing ctrl+r finds the receipts row; the same surface can never resolve "v", which is correct, because "v" there is a letter the user typed into a draft.
The returned entry is already projected onto the surface, so its Key is the chord the caller matched and not the one the catalog was seeded with.
func BySlash ¶
BySlash finds the entry whose Slash alias matches, case-insensitively — the composer lowercases nothing before matching today, so neither does this. Empty input never matches: an empty alias is not "no entry", it is every entry with no alias at all, and a caller asking BySlash("") almost always meant "nothing was typed yet."
func ForScope ¶
ForScope is AppendScope against a fresh slice, for a caller that has no buffer of its own to reuse (a one-off query, a test).
func (Entry) Destructive ¶
Destructive reports that this verb asks before it acts. It is the one-word form of "does this row carry a Entry.Confirm", named so a caller reads the question it is actually asking.
func (Entry) KeyOn ¶
KeyOn is the accelerator this entry actually has on surface, which is the only form of the question a render surface can honestly ask. It returns "" — no accelerator here — rather than a key the surface cannot bind.
On a SurfaceComposerFirst surface a recorded Entry.ChordKey wins, and a bare-letter Key resolves to nothing at all, because the composer will consume that letter as text. A chord Key ("ctrl+j", "alt+g") is bindable on every surface and is returned unchanged.
func (Entry) On ¶
On projects the entry onto a surface: the same id, verb, description, scope and journal, with Key resolved by Entry.KeyOn. It reports false when the entry has no accelerator on that surface, so a caller building a key-shaped strip (the contextual footer of 5.22 rule 4) can drop the row with one test and never has to correct a key by hand — a surface that hand-corrects the registry's keys is a second source of truth wearing the first one's clothes.
func (Entry) SteerFor ¶
SteerFor is this entry's composer seed for one named item, or "" when the verb is not a steering verb at all. A surface tests the empty answer to decide which of the two doors it is drawing — the seed, or the direct fire — so it never has to keep its own list of which verbs carry an argument.
type Journal ¶
type Journal struct {
// Kind is the store command kind the action journals as. Empty when the
// entry never journals (view toggles, local settings, reads).
Kind store.CommandKind
// Tool is the head belt tool name the entry resolves to, for the
// entries reachable only through ScopeTalk. Empty for everything else.
Tool string
}
Journal names the durable record an entry's action leaves, when it leaves one. Most entries here are pure surface — open a picker, scroll, toggle a view — and carry a zero Journal; that is a legitimate, common value, not a gap the seeding missed. Kind and Tool are never both set: a verb either journals through the ordinary command path (Kind, resolved against store.CommandKind) or is reached only through the head belt (Tool, the belt's own tool name) — never both, because the belt's control tool itself journals a Kind, and that is the entry the seeded catalog uses.
type Match ¶
Match pairs an entry with its score against one fuzzy query. Lower is a better match — the same convention internal/tui/commands.go's own fuzzyScore already uses for the model and cancel-target pickers, kept here so a render surface that knows that idiom does not have to learn a second one for the registry's rows.
func FuzzyMatch ¶
FuzzyMatch scores every entry in scope against query and returns the ones that match, best first, stable on ties. An empty query matches every entry in scope at score 0 — the palette's own convention that an untyped filter is not a filter, just the unfiltered list.
It is not reused from internal/tui/commands.go's fuzzyScore: that function is correct and does the same job, but it is unexported package-private state, and the dependency direction this package holds to is tui → registry, never the reverse — importing tui from here to borrow one function would invert it. The algorithm is restated instead, verbatim in behavior: an in-order subsequence match scored by how early each letter lands, with a flat bonus for a leading match.
type Scope ¶
type Scope uint8
Scope is a predicate over where an entry applies, expressed as a bitmask rather than a function: composing two scopes is a bitwise OR, and testing membership is a single AND. That keeps the predicate data — a static field on the entry — instead of growing into a zoo of small closures, one per surface, that a query function would have to know how to call.
const ( // ScopeThread is the room surface: the composer, the transcript, the // rail, and the header — everywhere the surface is showing while no task // is open for inspection. ScopeThread Scope = 1 << iota // ScopeNode is one task's activity view: its steer line and its feed. // Bare-letter accelerators here (c, r) are a different vocabulary than // ScopeThread's (v, y, Y) because the node view claims the keyboard // first and never falls through to the thread's own bindings. ScopeNode // ScopeTalk is reached only through the user's own words to the head — // the belt in internal/head/toolbelt.go — never through a key or a // slash alias. An entry scoped here still belongs in the one registry: // the capability-honesty surface (5.20 rule 3) has to be able to say // what the orchestrator can do, and a verb the belt carries is exactly // that, even with no accelerator of its own. ScopeTalk // ScopeItem is one thing the resident knows or is doing, open on its own // page: a belief, a way of working, a forged tool, a standing rule, a // service. The verbs here always act on THAT item — retire it, run it, // stop it — so the item is half the verb and the verb means nothing // without it. // // That is also why it is not in [ScopeAny]. Every other scope answers // "what can be done here"; this one answers "what can be done to this", // and an unscoped palette offering `retire` with nothing selected would be // a door that cannot open — the exact dishonesty 5.22 exists to remove. A // surface asks for ScopeItem when it has an item, and never otherwise. ScopeItem )
type Surface ¶
type Surface uint8
Surface is the registry's second binding axis. Scope says WHERE an entry applies; Surface says HOW the surface holding the keyboard there is even able to SPELL an accelerator.
They are different questions, and "toggle receipts" is the proof. The action applies in the room — ScopeThread — on both surfaces that have ever drawn a room. The v1 window routes it from a bare "v", because there the keyboard is not always the composer's. The v2 chat surface cannot: it is composer-first by design (5.15), a focused composer owns every printable key it is handed, and so it binds ctrl+r instead. One Key string cannot be true of both, and a registry that is the single source of truth (5.22) may not be false about either — a footer built from a row that names an unbindable key teaches a keystroke that does nothing, which is worse than teaching none.
The alternatives were both worse. Re-keying the row moves the lie rather than removing it. A second entry for the same action puts the verb in the palette twice and makes the catalog drift from itself by construction, which is the exact failure mode one registry exists to prevent.
const ( // SurfaceDefault is the surface an entry's [Entry.Key] is written for: one // where the keyboard is not permanently the composer's, so a bare letter // can be an accelerator. Zero value, so every query that does not ask // about surfaces keeps the answer it always gave. SurfaceDefault Surface = iota // SurfaceComposerFirst is a surface where a focused composer holds every // printable key, so only chords are bindable. A bare-letter entry has no // accelerator here — and saying so is the honest answer, not a gap: the // action still exists, still belongs in the palette and the `?` overlay, // and still reaches the user through the visible object it lives on // (5.22's law is that typing is the accelerator, never the only door). SurfaceComposerFirst )