Adr
0009 — Commands and keymap
- Status: Accepted
- Date: 2026-10-05
- Decides: STATUS.md — Core architecture / Commands and keymap; and the widget catalog’s third open question — how “key pressed” becomes “intent”.
- Depends on: ADR 0003 (
Widget.Handle(Event) bool, the interface this ADR deliberately does not change), ADR 0005 (Event,Key,KeyMod,Mouse, paste-as-one-event), ADR 0007 (§1 collision-avoidance rules), ADR 0008 (§1 “one shared type, named exactly this”, andNO_COLORas encode-time only) - Answers: the gap named in ADR 0005 — decoding is
done, and nothing sits above it. Every widget hand-rolls its own
switch ev.Key,examples/dashboard/main.gohand-rollsq,/,Tab,Escand an entire search mode, and there is no way to ask the program what keys it answers to.
Context
ADR 0005 ended with a term package whose gap was
decoding. That gap is closed. What is left is the gap immediately above it, and
it is visible in three places:
// widgets/data/list.go:309 — one case per documented binding
switch ev.Key {
case termmosaic.KeyUp:
l.move(-1)
// examples/dashboard/main.go:350 — the application's own routing, and it is
// three keys long on purpose
switch ev.Key {
case termmosaic.KeyTab, termmosaic.KeyBacktab:
// widgets/form/form.go:655 — Enter *and* the space bar, accepted by hand because
// "one terminal sends the rune and the other sends the Key" is not a fact any
// widget should re-derive
if ev.Key == termmosaic.KeyEnter || ev.Key == termmosaic.KeySpace {
Those three are the same problem at three layers, and the third one is the
tell: activateKey exists because two different Event values mean the same
user intent and nothing in the framework says so. A command layer’s first job
is not binding keys — it is having exactly one place where an Event becomes a
Chord, so that normalisation is stated once and every widget agrees.
What exists that a command layer must not collide with
| Already decided | Where | Why it constrains this ADR |
|---|---|---|
Widget.Handle(Event) bool, offered focused-first then to the tree |
ADR 0003, widget.go:31 |
24 widgets implement it. Touching it is the expensive option, and this ADR argues at length below that it does not need touching. |
Event is a tagged struct union, 112 bytes, Compose is the only nil-able field |
ADR 0005 §8 | A command layer must not add a payload to Event. Commands are resolved from an Event, not carried inside one. |
input.Decode is pure and 0 allocs/op on every key path, pinned by TestDecodeIsZeroAllocation |
ADR 0005 §8 | The dispatch path this ADR adds sits immediately downstream of it and inherits the bar. |
EventPaste carries a whole paste as one event, and must not be re-expanded into key events by the framework |
ADR 0005 §4 | A command layer that iterated a paste’s characters to find a command would violate that contract one layer up. It must not. |
Mouse has X, Y, Button, Action (MousePress/Release/Drag/Move), Mod; no click count, no held-button mask |
event.go |
Mouse→command design is constrained by what the decoder actually delivers. Drag is expressible; “click count 2” is not. |
kitty hyper, meta, caps-lock and num-lock modifier bits are decoded and dropped — KeyMod is 4 bits |
ADR 0005 §2 | A keymap that wants to bind Hyper+s cannot. Stated, not designed around. |
Key is a contiguous iota block ending at KeyF12; a new key must be appended |
event.go:68 |
The notation table must be built over the existing enum, not over an assumed one. |
form.Binding{Key, Help string} and KeyHint already exist |
widgets/form/keyhint.go:14 |
A discoverability type already ships. This ADR must generalise it, not re-invent a second one — and a type named Binding already exists in the tree, so this ADR may not name anything Binding. See §1. |
termmosaic.Minimizable, termmosaic.Focusable — the optional-interface pattern, discoverable by type assertion, no widget forced |
widget.go |
The command layer’s participation interface follows this precedent exactly. |
Prior art: OpenTUI’s @opentui/keymap
OpenTUI ships a separate @opentui/keymap package (host-agnostic, Bun/Node, no
FFI) for exactly this problem, and it is the only real reference implementation
available. Its shape, verified from the published docs 2026-10-05:
- Layers, each global or attached to a host target (
targetMode: focus-withinby default), sorted by explicit numericpriority, newer first on ties. “A local layer gets no automatic priority over a global layer.” - Named commands with metadata fields (
desc,group,title,category) and a handler returning a result that can reject and let dispatch continue. - A rich key language: literals, named keys, modifier chords, concatenated
sequences (
dd,g?),<token>aliases (<leader>s), and{count}-style runtime capture patterns — all registered through an addon pipeline with roughly 30 extension hooks (prependBindingParser,appendBindingExpander,registerSequencePattern,registerToken,preprocessLayerBindings, …), plus emacs chords,:commandparsing withaliases/nargs, and vim-neovim disambiguation. - Queries:
getActiveKeys,getCommands,getCommandEntries,getCommandBindings,getPendingSequence— the cheat-sheet and palette story is the reason the query surface exists. - Framework bindings for React and Solid, a browser (HTML
KeyboardEvent) host adapter, and a testing host.
We take the ideas and refuse the pipeline. Concretely:
| OpenTUI | TermMosaic | Why we differ |
|---|---|---|
Explicit numeric layer.priority, newest-first on ties |
Scope specificity is not configurable in v0.2: focus > screen > global | A priority integer an application gets wrong produces a binding nobody can predict and nobody can debug by reading. Our order has a justification (the focused widget is the most specific thing that can want the key) and a name. |
fallthrough and preventDefault as separate binding flags |
No fallthrough. A handler returning false continues. |
Two flags for one concept, where one of them is a Go return value. |
runCommand (ignores activation) vs dispatchCommand (respects it) |
Same distinction, named Invoke and Dispatch |
Convergent, and the names are clearer. |
~30 prepend*/append* extension hooks |
None. A keymap package with a plugin pipeline for a framework that rejects reconcilers |
ADR 0003’s central argument applies here too: an extension surface is a silent-failure surface, and we have no plugin story to serve. |
Concatenated sequences, <leader> tokens, {count} patterns |
Deferred with a trigger (§8) | Each needs pending-sequence state, and pending-sequence state is input-layer state ADR 0005 already owns. Building it here creates a second ESC-ambiguity policy. |
:write ex-commands with aliases/nargs |
Deferred with a trigger (§8) | A command line is a whole sub-language — parsing, history, completion, a KeyHint for it. Not v0.2. |
Two hosts (terminal + DOM KeyboardEvent), which is why the engine is host-agnostic |
One host: term mosaic.Event |
ADR 0005 already unified it. A host-agnostic engine is the right shape when you have two hosts. |
Ctrl+K palette, ? help |
Adopted verbatim | Everyone agrees; convergence is the reason to adopt rather than invent. |
Options considered
Where the command layer lives in the event pipeline
This is the decision everything else follows from, so it is argued first and at length.
Option A — sit above the tree, leave Widget.Handle alone. The application
loop asks the keymap first; if the keymap does not consume the event, the event
goes to the widget tree exactly as it does today.
Option B — replace Widget.Handle(Event) bool with Handle(Event, Context)
or a command-only interface, so every widget declares commands instead of
switching on keys.
Option B is what a “full command layer” usually looks like, and it is rejected on cost, not on taste:
- 24 widgets change, plus
render’s dispatch, plus everywidgettesthelper, plus everyHandlecall site inexamples/. That is a signature change on the one interface the README tells users is the framework’s whole contract. - It does not even solve the stated problem for widgets. A
List’sKeyPageUphandler isl.move(-l.vm.Visible() + 1)— it needsl.vm.Visible(), a live field of the widget. Expressing that as a registry entry means the widget’s internal state becomes a registry key, or the registry entry is a closure over the widget, or the widget keeps a private command table anyway. You end up with the keymap plus a private keymap. - It loses
TextInput. A text field must receive every printable rune, including the ones no command is bound to, and must seeEventPasteas one event (ADR 0005 §4). A widget that only handles declared commands cannot accept a character nobody declared a command for. - It loses mouse hit-testing, which is the one thing widgets are genuinely better at than a global registry (§6).
Option A’s cost, stated honestly: an application can now have keys answered in
two places — the keymap and Handle — and precedence between them is a
discipline rather than a type. That is a real sharp edge and it is why the
resolution order below is spelled out as four numbered steps and pinned by a
test.
Decision: Option A. termmosaic.Widget is unchanged. No method added, none
changed, none deprecated. The Handle doc comment gains one sentence about
precedence, and that is the only edit to widget.go.
One registry, or one per screen?
A single application-wide registry with per-command scope, versus a registry per screen so switching screens swaps the bindings wholesale.
The second is what makes a modal dialog easy: a dialog owns its Esc and its own
navigation keys, and closing it cannot leave them behind. But it makes help
impossible to answer honestly (“what keys does this program have?”) and makes
rebinding a global key an act of mutating every screen’s table.
Decision: one keymap.Registry per application, with scope on the binding.
A screen is registered as a scope holder; pushing and popping a screen is a
binding concern, not a registry concern. This keeps Describe answerable, which
is the thing §5 depends on.
Should widgets be forced to participate?
Forcing it would make help complete on day one. Forcing it also means 24 widgets, three of which are being written right now by other agents against a different ADR, must be edited by this one.
Decision: participation is an optional interface, and v0.2 requires it of
exactly zero catalog widgets. The honest consequence — a List’s arrow keys
are not in the palette unless it opts in — is recorded in §Consequences rather
than papered over.
Decision
A named command is a thing an application can invoke; a key is one way to
invoke it. The keymap maps a decoded Event to a command through a single
normalisation step, resolves it by context specificity, and exposes the registry
itself as the only source of discoverability data. Widget.Handle is untouched
and sits below.
input.Source (ADR 0005 — decoding, ordering, paste-as-one)
│ Event
▼
┌───────────────────────────────────────────────────────────────────┐
│ APPLICATION EVENT LOOP (application's code) │
│ │
│ 1. EventResize → resize, recompute bounds, r.Resize │
│ 2. EventPaste/Focus → straight to the tree, NEVER Dispatch │
│ 3. everything else → km.Dispatch(ev, focus) │
│ ├─ consumed → done │
│ └─ not → focus.Handle(ev), then root.Handle(ev) │
└───────────────────────────────────────────────────────────────────┘
▲ │ │
│ registry │ optional │ unchanged
│ ▼ ▼
keymap.Registry ──▶ Commandable ──▶ (24 widgets) Widget.Handle(Event) bool
│ Clickable ADR 0003, untouched
│
└──▶ Describe() ──▶ help screen / KeyHint / command palette
1. The shared vocabulary, by name and signature
Every name below is reserved framework vocabulary and must be added to
vocabulary_test.go’s forbiddenDecls (§Forced changes), exactly as ADR 0007
§1 rule 4 and ADR 0008 required for Wrap, Style and Budget. A package that
defines its own Chord has re-invented the thing this ADR exists to
prevent.
Everything lives in a new package keymap, which imports termmosaic and
buffer and nothing else. That direction is forced and safe: termmosaic does
not import keymap, so widgets/* may import it without a cycle, and keymap
may hold Widget values without the root package holding keymap values.
package keymap
// ---------------------------------------------------------------------------
// Command identity and the command itself
// ---------------------------------------------------------------------------
// CommandID is a command's stable, globally unique name, conventionally
// dotted and lower-case: "file.save", "view.toggle-help", "list.confirm".
//
// A string and not a distinct type because it crosses into help text, into
// config files the application owns, and into log lines. Two packages that
// both spell a command name must spell it the same way; a defined type would
// prevent the copy, not the typo.
type CommandID string
// Command is a named action an application can invoke from a key, a click, a
// palette row, or its own code.
//
// The registry holds commands and bindings SEPARATELY, because a command may
// have several chords and a chord may have no command (see Set.Bind's rule on
// ordering). This is the same separation Bubble Tea and OpenTUI use and it is
// the reason a palette can list "save" once and show three keys beside it.
type Command struct {
// ID is the command's name. Required and unique within a Registry; a
// second Command with an existing ID replaces the first.
ID CommandID
// Desc is the one-line description shown in help, in the palette and in a
// KeyHint. It is REQUIRED for any command a user can reach — a command
// with an empty Desc is a warning from the Registry, not a silent blank
// row — with one exception: a command may legitimately have no description
// if Hidden is true.
Desc string
// Group orders commands in help and groups palette rows. Empty means the
// uncategorised bucket, which help renders last under an empty heading.
// Groups are sorted alphabetically; within a group, commands are sorted by
// ID so that help output is stable across runs and diffable across
// versions.
Group string
// Run is the handler. Returning false means "I did not handle this after
// all", and Dispatch continues to the next candidate binding. That is the
// ONLY fallthrough mechanism in TermMosaic — there is no fallthrough flag
// and no preventDefault flag, because a Go return value is the same
// mechanism wearing a hat.
//
// Run must not mutate widget state from another goroutine. ADR 0003's rule
// applies without exception: mutation happens inside Renderer.Post, or on
// the event goroutine where the application's own state already lives.
Run func(Ctx)
// Enabled reports whether the command is currently available. It gates
// palette rows, help rows and Dispatch: an unavailable command is not run
// by a key press. Nil means always available, and is the common case.
//
// It is a plain predicate, not a condition expression language, and it MUST
// NOT be called on the dispatch hot path more than once per candidate
// binding. See §2's allocation note.
Enabled func() bool
}
// Ctx is what a command handler is given.
//
// It is passed BY VALUE and is 128 bytes — see amendment 1: the shipped size
// is 152, pinned by TestCtxIsOneHundredFiftyTwoBytes.
// On a path that runs at most a few
// hundred times per second, against ADR 0002's 16 ms frame budget, that is the
// same trade ADR 0005 §8 made for the 112-byte Event: the zero-allocation bar
// is about not allocating, not about struct copies. Passing *Ctx would put an
// escaping pointer on the path and turn a stack copy into a heap object per
// keystroke.
type Ctx struct {
// Event is the event that produced this dispatch, by value. A command
// invoked from the palette or from code gets the zero Event, and
// Synthesised is true.
Event termmosaic.Event
// Chord is the key chord that produced this dispatch, normalised as §3
// specifies. It is the zero Chord when the command was invoked from a
// click, from the palette, or from code — which is why a handler that
// needs "which key was this" must check Chord.IsZero() first.
Chord Chord
// Focus is the widget that had keyboard focus when the command was
// dispatched, and is nil when nothing is focused. A handler that needs to
// act on the focused widget type-asserts it; it does not receive a
// command's "target widget", because there is no such thing (§2).
Focus termmosaic.Widget
// Synthesised is true when the command was invoked without an Event —
// from the palette, from the command line, or by Invoke from application
// code. A handler that behaves differently for a mouse activation than for
// a keyboard activation reads this; a handler that does not care ignores
// it, and the overwhelmingly common case is to ignore it.
Synthesised bool
}
// ---------------------------------------------------------------------------
// Scope — where a binding is live
// ---------------------------------------------------------------------------
// Scope says how specific a binding's context is. The three values are a total
// order and they are the only ones; there is deliberately no numeric priority
// to get wrong (see the OpenTUI comparison above).
//
// Context specificity is NOT configurable. An application that needs
// something between Screen and Focus declares a Screen binding on a narrower
// screen, which is the mechanism the value already has.
type Scope uint8
const (
// ScopeGlobal is live everywhere, in every focus context and on every
// screen. This is where app.quit, app.help and app.palette live.
ScopeGlobal Scope = iota
// ScopeScreen is live only while the screen that declared it is the
// current one. Registry.SetScreen declares which widget is current, so a
// dialog pushes a screen and its bindings are live for exactly as long as
// it is up.
ScopeScreen
// ScopeFocus is live only while the widget that declared it holds
// keyboard focus. This is where a list's navigation keys belong IF the
// list chooses to declare them (§3's Commandable interface).
ScopeFocus
)
// String returns the scope's name, for help output and diagnostics.
func (s Scope) String() string
// ---------------------------------------------------------------------------
// Chord — one normalised key plus modifiers
// ---------------------------------------------------------------------------
// Chord is one user-input gesture, normalised: exactly one key, plus exactly
// the modifiers that were held.
//
// It is comparable, which is the load-bearing property: a Chord is a map key,
// so resolution is one map lookup and zero allocations. It is also exactly 16
// bytes, and TestChordIsSixteenBytes pins that the way TestCellHasNoPadding
// pins ADR 0002's Cell — because a struct on the hot path that nobody measures
// is how a hot path becomes slow.
//
// NORMALISATION, and this is the whole reason Chord exists:
//
// - Space is ONE chord. KeySpace and Rune ' ' are the same gesture, because
// terminals disagree about which they send and form.activateKey already
// works around this; the Chord type makes the workaround the default.
// - Shift on a printable rune is FOLDED INTO THE RUNE. A terminal sends
// Shift+A as 'A', never as 'a' with a shift bit, so Chord{'A'} and
// Chord{'a', ModShift} would be two chords for one gesture.
// - Ctrl on a printable rune is KEPT, because 'a' with Ctrl is genuinely
// distinguishable from 'a'.
// - Ctrl+I, Ctrl+M, Ctrl+J and Ctrl+H are folded to Tab, Enter, Enter and
// Backspace where the decoder folds them, so a chord the terminal cannot
// distinguish is not two chords either. The consequence — that Ctrl+M and
// Enter are the same chord on a legacy encoding, and different ones under
// kitty disambiguation — is recorded in §Consequences rather than papered
// over.
type Chord struct {
// Key is the non-printable key, or KeyNone when Rune carries the gesture.
Key termmosaic.Key
// Rune is the printable character, and is 0 whenever Key is not KeyNone.
Rune rune
// Mod is the normalised modifier set. ModShift is never set when Rune is
// non-zero.
Mod termmosaic.KeyMod
}
// IsZero reports whether c is the zero Chord, which is not a legal binding and
// is what a click, a palette activation or a direct Invoke carries.
func (c Chord) IsZero() bool
// String returns the canonical display form: "Ctrl+K", "Shift+Enter", "F1",
// "?", " " (a single space character, which is why the hint brackets it).
//
// Display is deliberately the SAME function as parsing, modulo case: what help
// prints is what ParseChord accepts. A help screen that documents a key nobody
// can bind is worse than one that documents fewer keys, and the way to make
// that impossible is for there to be one function, not two that agree.
func (c Chord) String() string
// ParseChord parses the canonical display form back into a Chord, applying the
// same normalisation as ChordOf. It is the inverse of Chord.String() for every
// chord that can be produced by a terminal, and TestParseChordRoundTrips pins
// that over a generated table.
//
// It is the API a REBINDING UI uses: the user types a key, the application
// parses it, and if it fails the application says so rather than storing a
// binding that will never fire.
func ParseChord(s string) (Chord, error)
// ChordOf converts a decoded key event into its Chord, applying §3's
// normalisation. It returns false for any event that is not an EventKey, and
// for a key event that normalises to nothing.
//
// This is the one place in TermMosaic where an Event becomes a gesture, and it
// is exported so that a widget keeping its own switch can ask "is this the key I
// care about?" without re-deriving the folding rules.
func ChordOf(ev termmosaic.Event) (Chord, bool)
// ---------------------------------------------------------------------------
// Binding — a chord, in a scope, on a target, reaching a command
// ---------------------------------------------------------------------------
// Binding connects a Chord to a CommandID within a Scope.
//
// It is a value, not a heap object, and it is what Commandable returns. The
// name is Binding rather than KeyBinding because the term is unqualified
// anywhere in TermMosaic, and because form.Binding already exists and is a
// different thing (a hint, §5) — the collision rule of ADR 0007 §1 rule 4 and
// ADR 0008 forbids a second exported Binding, so the new vocabulary takes the
// unused name and the existing one is left alone.
type Binding struct {
// Chord is the gesture. The zero Chord is rejected by Registry.Bind.
Chord Chord
// ID is the command it reaches. It need not be registered yet: a binding
// may precede its command, because a screen's bindings are naturally
// declared while its fields are being constructed.
ID CommandID
// Scope is the binding's context. Zero value is ScopeGlobal.
Scope Scope
// Owner is the widget a ScopeFocus or ScopeScreen binding belongs to. It
// is required when Scope is not ScopeGlobal and must be nil when it is.
// Identity is pointer identity, so a binding follows the widget instance
// that registered it — which is what makes a widget that is rebuilt each
// frame a widget that must re-register, and that hazard is recorded in
// §Consequences.
Owner termmosaic.Widget
// Desc OVERRIDES the command's own Desc for this binding only. It exists
// because the same command means different things in different contexts —
// Enter is "confirm" in a dialog and "open" in a browser — and it is the
// field that lets one command appear twice in help with two honest
// descriptions. Empty means "use the command's Desc".
Desc string
}
// ---------------------------------------------------------------------------
// Entry — one row of discoverability output
// ---------------------------------------------------------------------------
// Entry is one discoverable row: a command, its description, and the chords
// that currently reach it.
//
// It is the ONLY type the help screen, the command palette and KeyHint consume,
// and it is produced from the registry, which is what makes it impossible for
// help to drift from the bindings (§5).
//
// Entry allocates. It is built on demand, never inside Draw, and never on the
// dispatch path.
type Entry struct {
// ID is the command.
ID CommandID
// Desc is the description to show — the binding's override if it has one,
// otherwise the command's.
Desc string
// Group is the display group.
Group string
// Chords are the chords that reach this command in the current context,
// in canonical order (modifiers ascending, then Key ascending, then
// rune). A command with several bindings produces several Rows, not one
// row with a chord list: a help screen that shows one row per chord is
// easier to scan and is what every terminal help screen does.
//
// The name Rows is deliberately not used for the type because Entry is the
// thing and a row is a view of it.
Chords []Chord
}
Three names in that block deserve their reasoning stated outside the comment, because a coder will otherwise “improve” them.
Why Ctx and not a []string argv. OpenTUI’s :write ex-commands carry
aliases and nargs, and the natural pull here is to add
Args []string to Command so a palette can invoke list.select with an
argument. Deferred, with a trigger (§8). A command taking arguments is a command
language: parsing, completion, quoting, and a text-input mode for the palette.
Adding Args first would put the parsing in every handler instead of in one
place, and would make Describe ambiguous — is list.select available when the
palette filters to “list”? Keep Ctx argument-free in v0.2 and the door stays
open.
Why Enabled func() bool and not a field. A field is set once; a predicate
reads live state. The cost is that it is a function value called on the hot path
when a chord matches, which is why the doc comment says “not more than once per
candidate binding” and why §2 pins the allocation count including a matching
chord with a non-nil Enabled.
Why Owner is a Widget and not a string ID. A string ID would need a
lookup to become a widget, and the lookup is where “which of my twelve lists is
this” ambiguity lives. Pointer identity is unambiguous, and the rebuild hazard it
creates is real, nameable, and cheaper than the alternative.
2. Keymap resolution: four steps, and a Handle that never moves
The dispatch path, in order. This is the whole contract and it is four steps because there are four places an event can go.
Dispatch(ev, focus) — in this exact order:
1. Ev is not EventKey or EventMouse → return not-consumed. Unconditionally.
Resize, Paste, Focus and Compose never enter a command layer.
2. Build the Chord (KeyOf) → for keys; for a mouse, resolve the
owning widget by hit-test (§6).
3. Resolve the command → §2.1 precedence.
4. Run it. If Enabled is non-nil and
false → NOT consumed, continue to
the next candidate. If Run returns
false → NOT consumed, continue. → consumed = true, stop.
Step 1 is not defensive programming, it is ADR 0005
§4’s paste rule enforced one layer up. If Dispatch iterated a paste’s
characters looking for a command, a 10,000-character paste would run the
resolution loop 10,000 times, which is the exact failure the parser was designed
to prevent. Paste goes to the tree, whole, once. This is stated as a hard rule
because it is the cheapest possible mistake to make and the most expensive to
discover.
Step 4’s two “not consumed” exits are one mechanism. Enabled false means the
command is not available; Run returning false means it declined. Both continue
to the next candidate. There is no flag to remember which happened, because
there is nothing to do differently afterwards.
2.1 Precedence, in order
Candidates are tried highest first; the first one that passes Enabled runs.
| Rank | Kind | Wins because |
|---|---|---|
| 1 | User override, any scope | An explicit Bind after SetDefaults is a deliberate act. It outranks a default in the same scope, and a ScopeScreen override outranks a ScopeGlobal default, because specificity outranks everything else. |
| 2 | ScopeFocus default, Owner == focus |
The focused widget is the most specific thing that can want the key. |
| 3 | ScopeScreen default, Owner == screen |
The screen is more specific than the application. |
| 4 | ScopeGlobal default |
The application’s own keys. |
Ties within one rank are resolved by registration order, and a later registration replaces an earlier one. Not by an error: replacing is what makes an override an override.
Why an override does not outrank a more specific scope. The tempting rule —
“the user’s binding always wins” — breaks the case that matters most. A user who
binds Esc to app.cancel in order to close a dialog would, under that rule,
lose the ability to close the dialog to any screen that binds Esc itself. The
specificity order means the dialog’s Esc is still the dialog’s, and the user’s
global Esc applies everywhere else. This is the same reasoning as CSS
specificity and it is the reason we did not adopt a numeric priority.
The candidate list is a slice walked in rank order, not a lookup that returns
“the” answer. At most a handful of candidates exist per chord, and walking
them is what makes Enabled and Run both able to decline. The per-candidate
slice is built once, when the registry is sealed or when SetScreen changes, and
is cached on the registry — so walking it allocates nothing.
The allocation claim, pinned. Dispatch allocates zero times for every
event kind, including a key event that matches a command with a non-nil
Enabled, and including the miss path. Pinned by
TestDispatchIsZeroAllocation in the shape of input’s existing test:
| Path | allocs | Why it is achievable |
|---|---|---|
| key, no match | 0 | Three map probes on a comparable 16-byte key, or one linear scan of a sealed slice. |
key, match, Enabled == nil |
0 | Same, plus one indirect call on a Ctx built on the stack. |
key, match, Enabled != nil |
0 | A func() bool field read from a struct in the registry is not a boxing allocation. |
key, match, Run declines, next candidate runs |
0 | The candidate walk is over a pre-built slice. |
mouse, press, no Clickable widget at point |
0 | Hit-test is a Bounds().Contains per registered widget. |
Describe, Chords, Help |
1+ | These build strings and slices. They must never be called from Dispatch, and the test above does not call them. |
Dispatch returns (CommandID, bool), not bool. The name comes back
because an application needs it for its own logging and for “did anything handle
this key” logic, and returning it costs nothing (a string header in a register
pair).
2.2 What the application loop looks like
This is the shape every application adopts, and it is four lines longer than the
loop in examples/dashboard/main.go today:
for ev := range src.Events() { // ADR 0005: one ordered stream
switch {
case ev.Kind == termmosaic.EventKey || ev.Kind == termmosaic.EventMouse:
if km.Dispatch(ev, focus) { // commands first
invalidate()
continue
}
// Unconsumed: the tree, exactly as ADR 0003 §"Frame pipeline" says.
if !focus.Handle(ev) {
root.Handle(ev)
}
case ev.Kind == termmosaic.EventResize:
// ADR 0007 §5 order, unchanged: recompute bounds, then Resize.
root.SetBounds(rootBounds(ev.Size.W, ev.Size.H))
r.Resize(ev.Size.W, ev.Size.H)
default:
// Paste, focus, compose: straight to the tree, never Dispatch (§2 step 1).
if !focus.Handle(ev) {
root.Handle(ev)
}
}
invalidate()
}
Two rules a coder must not get wrong, so both are stated as rules:
- Resize is not a command. There is no
resizecommand and there will not be one. A resize is not an intent; it is a fact about the world, and ADR 0007 §5 already gives it its place in the loop. - A key that no command consumes still reaches the tree. This is what keeps
TextInputworking (§2.3) and it is the reasonDispatchreturning false is a normal outcome rather than a bug.
2.3 Widget.Handle does not change — the argument
termmosaic.Widget is unchanged. No method is added, none is changed, none is
deprecated. Stating the cost of the alternative, because the alternative is
reasonable and a reviewer should be able to check this reasoning:
| Change | Cost |
|---|---|
Handle(Event) bool → Handle(Event, keymap.Ctx) bool |
24 widget signatures, render’s dispatch, every widgettest helper, every Handle call site in three examples, and a root-package import cycle unless Ctx moves into termmosaic. |
Add a fifth method Commands() []keymap.Binding to Widget |
Not a cycle (it is an interface method, so termmosaic names no keymap type), but it forces 24 widgets to implement a method they cannot usefully implement, and ADR 0007 §“Rejected” already rejected a mandatory method for exactly this reason: a method a widget can forget is a silent-failure surface. |
Make Handle take a CommandID instead of an Event |
Breaks TextInput (it needs undeclared runes and whole pastes), breaks mouse hit-testing (a widget cannot name a command per row), and breaks every widget that is not a command table. |
What the command layer would have to do to make the change worth it, which is
why it is not worth it yet: own all keys, including TextInput’s printable
runes and List’s viewport-relative paging. That is a text-editing model and a
virtualised-scroll model, not a keymap. The first two of those do not exist yet.
The one edit to widget.go is a doc comment, and it is load-bearing enough
to quote:
Handle offers the event to the widget and reports whether it consumed it. Events reach a widget only after the application’s keymap has declined them (ADR 0009), so a binding in a keymap shadows a
switchin this widget. A widget’s own key handling is therefore the fallback, not the primary path, and a key that matters to both should be a command.
That is the whole concession, and it is honest: the two mechanisms can both answer a key, and the winner is the keymap.
3. Key notation: one function, both directions
The notation is not a spec someone else can implement differently; it is two functions in one file, and the second is the inverse of the first.
Grammar. Modifiers joined by + in the fixed order Ctrl, Alt, Shift,
Super, then the key. Modifier names are matched case-insensitively on
parse and emitted capitalised, which is what makes ctrl+k and Ctrl+K the
same binding. The full form:
| Written | Means | Canonical form |
|---|---|---|
Ctrl+K |
ModCtrl + the key k |
Ctrl+K |
ctrl+shift+s |
`ModCtrl | ModShift+s` |
Shift+Enter |
ModShift + KeyEnter |
Shift+Enter |
? |
the printable rune ?, no modifiers |
? |
F1 … F12 |
the function key | F1 |
Space |
the space bar | Space — and (a literal space) normalises to the same Chord |
Esc |
KeyEscape. Escape is an accepted alias on parse |
Esc |
Ctrl+? |
ModCtrl + ? |
Ctrl+? |
Three notation decisions that would each have been a divergence:
- Space has two spellings and one meaning.
KeySpaceandRune ' 'are the same gesture; terminals disagree, andform.activateKeyalready works around it by hand.Chordfolds them,ChordOffolds them coming from anEvent, andParseChord("Space")andParseChord(" ")produce the same value. Canonical display isSpace— a single space character in a help row is invisible to a reader, and the whole reasonKeyHintbrackets its keys (an accessibility rule already in that widget) is that shape carries meaning that whitespace cannot. - The
Key.String()table inevent.gois the authority for key names, which already yieldsesc,del,pgup,f1.Chord.String()maps them to display forms (Esc,Del,PgUp,F1) through one table inkeymap, andParseChordmaps them back through that same table. Two hand-written tables would drift, and drift here is a key that is documented and does not work — the most expensive class of help bug. Ctrl+IandCtrl+MareCtrl+TabandCtrl+Enter. ADR 0005 §2 records that the kitty disambiguate flag is precisely what makes these distinguishable, and that they are indistinguishable without it.Chordfollows the decoder: where the decoder folds, the Chord folds, so a binding cannot exist for a gesture the terminal cannot express. The cost is thatCtrl+MandEnterare one chord on a legacy encoding — stated in §Consequences, and it is the decoder’s property, not this ADR’s.
NO_COLOR and the 16-colour rung: nothing to do, and that is a decision.
The notation is plain text. A chord renders as bracketed, capitalised ASCII-ish
text in the terminal’s own foreground, which is exactly what KeyHint already
does with no colour at all. So a help screen, a palette and a KeyHint row are
identical under NO_COLOR, under a 16-colour terminal, and under truecolor —
because no chord carries a buffer.Style field and no code path consults colour.
ADR 0008 §3’s rule (“nothing in the widget path knows
about NO_COLOR”) is inherited, not extended: this layer does not have a widget
path and does not have a colour path.
Round-tripping is pinned, not asserted. TestParseChordRoundTrips walks a
generated table of every Key constant × every one-modifier subset × a set of
printable runes, and asserts ParseChord(c.String()) == c. It runs against the
real Key enum, so it fails by itself when KeyF13 is appended at the end of
the iota block (ADR 0005 §10) and nobody remembered the notation table.
4. Discoverability: help is the registry, or it is not help
Registry.Describe(scope) is the single source of discoverability truth, and
everything user-facing is built from its output. There is no second list of
keys anywhere in TermMosaic, which means the class of bug where help documents a
key that was renamed is structurally impossible rather than merely unlikely.
// Describe returns the discoverable entries for scope, sorted by Group then
// ID. It is the ONLY data the help screen, the command palette and a KeyHint
// bound to a registry consume.
//
// It allocates, it is not cheap, and it MUST NOT be called from Dispatch or
// from a widget's Draw. A help screen that is open calls it when the registry
// changes; a palette calls it when it opens; a KeyHint calls it when its
// context changes. See §2's allocation table.
func (r *Registry) Describe(scope Scope) []Entry
// Chords returns the chords currently bound to id in scope, in canonical
// order. The cheap single-command query, for a KeyHint that shows one row.
func (r *Registry) Chords(id CommandID, scope Scope) []Chord
// Has reports whether id is registered and available. A one-line predicate for
// the "enable this button" question, which is asked on every frame of a toolbar.
func (r *Registry) Has(id CommandID) bool
Three rules that make “help cannot drift” true rather than aspirational:
- A command with an empty
Descis a warning from the Registry, not a silent blank row.Registry.Warnings() []stringreports it, along with a binding that names an unregistered command, a binding whoseOwneris nil under a scopedScope, and a chord bound twice in one scope. Warnings are diagnostic surface, never control flow — the same stanceinput.Statstakes in ADR 0005 §1. Describeis derived from the resolution tables, not from a parallel index. There is no[]Entrythe application maintains; it is computed from the sameBindingvaluesDispatchwalks. A binding that cannot be reached by resolution cannot appear in help.- A command with no chord is still describable.
list.confirmbound to Enter on aListandbutton.activatereachable only from a palette row both appear. A discoverability list that only lists keys is a key list, not a command list, and a mouse-only or palette-only command is invisible in a key-only help screen.
4.1 Should KeyHint be generalised? Yes, in one direction only
KeyHint already exists, already brackets its keys for accessibility, and
already truncates rather than clips. It is not replaced and its constructor is
not changed. What it gains is one method:
// SetEntries replaces the bindings with rows derived from a command registry,
// so a form's hints cannot disagree with the program's bindings. It is the
// ADR 0009 discoverability path for the existing widget.
//
// SetEntries accepts what Describe returns and nothing else: a hand-written
// []Binding remains supported for the case where the application has no
// registry, and both produce identical rows.
func (k *KeyHint) SetEntries(entries []keymap.Entry)
This is the ADR’s only touch to the widget catalog, it is additive, and it is
deliberately one method rather than a signature change — three agents are
building widgets in widgets/ right now against other ADRs, and a changed
KeyHint.Bindings field type would collide with their work. The
form.Binding → keymap.Entry reconciliation is deferred with a trigger
(§8), because it is a breaking change to a shipped widget and there is no
deadline forcing it.
5. Rebinding: yes in process, no on disk
Users can change bindings at runtime. Three operations, and the precedence rule is §2.1’s rather than a new one:
// Bind adds a binding. A binding added after Seal takes rank 1 — the
// user-override rank — within its scope. This is how SetDefaults distinguishes
// a program's own bindings from a user's or a config file's.
func (r *Registry) Bind(b Binding)
// Unbind removes every binding for c in scope. A chord that was defaulting to
// something becomes unbound rather than falling back to an older default,
// which is what a user who pressed "unbind this key" meant.
func (r *Registry) Unbind(c Chord, scope Scope, owner termmosaic.Widget)
// BindString parses s and binds it, reporting a parse failure. This is the
// entry point for a rebinding UI and for a config file, so the parse error
// surfaces where the user typed it rather than as a binding that never fires.
func (r *Registry) BindString(s string, id CommandID, scope Scope, owner termmosaic.Widget) error
Persistence to disk is explicitly NOT a framework concern, and this is a
decision rather than an omission. The order of operations is the reason, and it
is worth stating because “just add a JSON loader” is the obvious next request: to
persist and restore, the application must enumerate ScopeGlobal and ScopeFocus
bindings, and the framework does not know which widgets exist until the application
attaches them. A loader written before that is a loader that cannot be correct.
The trigger is named in §8.
Runtime rebinding of a ScopeFocus binding whose owner was rebuilt is a real
hazard, and it is the same shape as ADR 0007 §3’s cache-keyed-on-the-wrong-
thing bug: a widget that is reconstructed gets a new pointer identity, so its
old bindings silently stop matching. Registry.Warnings() reports bindings whose
owner no longer answers to Registry.IsAttached(owner), which is the only
mechanism that catches it before a user reports “my key stopped working”.
6. Mouse: a hit-test, never a rectangle
A click becomes a command because the widget under the pointer says so. The registry does not hold rectangles.
The alternative — binding a command to a Rect — is rejected on the same
argument ADR 0007 §1 rule 1 uses for Bounds() versus buf.Size(): a rect
stored in a global registry is a second source of truth for geometry, and it
goes stale the moment the layout solver reflows, the window resizes, or a
virtualised list scrolls. A binding registered for “the delete button at
(72, 3)” is a binding that silently misfires on a 100-column terminal. The
widgets own their geometry; the registry owns the names.
So a command reachable by mouse is reached through the focused widget, which is the widget that already knows what is under the cursor:
// Clickable is the OPTIONAL interface a Widget implements when a click inside
// its bounds can mean a command — a Button, a Table cell, a Menu row, a
// dismissible backdrop.
//
// It is one method returning a name, not a handler, because the command
// registry is the only place handlers live. A widget that implements it never
// runs application logic itself; it says which command the user meant.
//
// A widget that does not implement it is fully supported. Every widget in the
// v0.2 catalog does not implement it.
type Clickable interface {
// Command reports the command a click at (x, y) — in SCREEN coordinates,
// the same coordinates EventMouse carries — means, or ("", false) if the
// click is not a command activation.
//
// false is a normal answer, not an error: a click on a widget's padding
// is a click on the widget and not a command. It means Dispatch continues,
// which is how a click falls through to Handle for widgets that have both
// interfaces.
Command(x, y int) (CommandID, bool)
}
The drag question, answered precisely, and the answer is “not in v0.2”.
Event.Mouse.Action already carries MouseDrag (ADR
0005 §3), so a drag is expressible and needs no
new decoding. What a command layer cannot do in v0.2 is give a drag a meaning,
because a drag is a three-phase gesture — press, motion, release — and a command
is one instantaneous intent. Options considered:
| Option | Verdict |
|---|---|
Synthesise a Drag command fired on MouseDrag motion |
Rejected. The first motion event fires it, the user gets no drag, and the release is unhandled. A drag that fires a command on the first moved cell is a bug report, not a feature. |
Add a DragCommandable interface returning three callbacks |
Rejected for v0.2. It is the same shape as Clickable with a lifetime attached, and it is only worth an interface once a widget needs it. |
Leave drag to Handle, which is where it already works |
Adopted. A widget that needs a drag already receives MouseDrag events in Handle and already implements the gesture. keymap adds nothing, and §8 names the trigger for revisiting. |
A wheel notch is the same shape as a click and is expressible: a Command(x, y) on a scrollable widget returning view.scroll-down for a wheel notch over
its rows. Whether any catalog widget does this in v0.2 is a widget decision, not
this ADR’s, and none is required to.
7. Commandable: how a widget contributes, and why it is optional
The whole participation surface is one optional interface with one method. It
mirrors termmosaic.Focusable and termmosaic.Minimizable exactly: optional, so
adding it costs no widget anything, and discoverable by a type assertion.
// Commandable is the OPTIONAL interface a Widget implements to publish its own
// key bindings to the command registry — so they appear in help, in the
// command palette, and in a rebinding UI, and so an application's global
// binding can be overridden consistently with a widget's.
//
// It publishes; it does not dispatch. Widgets keep receiving unconsumed keys
// through Handle, and Dispatch never calls Commandable. The two mechanisms are
// independent by design (§2.3).
//
// A widget that does not implement it is fully supported and is the v0.2 norm
// for the whole catalog. Its keys work; they are simply not discoverable
// through the registry, and KeyHint remains the way it advertises them.
type Commandable interface {
// Bindings returns the widget's bindings. Scope is ScopeFocus and Owner is
// the widget itself; the Registry fills both in, so an implementation
// returns only Chord, ID and an optional Desc override.
//
// It is called on Attach and on every Registry.Seal, never per keystroke,
// and it is allowed to allocate — it is not on the dispatch path.
Bindings() []Binding
}
Two rules make this safe, and both are rules about when it is called rather than about what it returns:
- Bindings are pulled at
Registry.Attachand atRegistry.Seal, never on the dispatch path. A widget whose bindings depend on mutable state (aTextAreawhoseCtrl+Zdepends on the undo stack being non-empty) publishes the command and letsEnabledcarry the condition, not the binding. This keepsBindableoff the hot path by construction. Owneris the widget, so a widget rebuilt per frame must re-attach. AWidgetvalue rebuilt each frame is already pathological under ADR 0003 (its identity is what focus and invalidation hang on), and this ADR inherits that rather than solving it.Registry.Warnings()reports an attached widget that is no longer attached.
8. Scope: v0.2, deferred, and triggers
In v0.2, and that is the whole of it. Stated as a block so it cannot be lost:
| Symbol | File |
|---|---|
keymap.CommandID, Command, Ctx |
keymap/command.go |
keymap.Scope + ScopeGlobal / ScopeScreen / ScopeFocus, String() |
keymap/scope.go |
keymap.Chord, IsZero, String, ParseChord, ChordOf |
keymap/chord.go |
keymap.Binding |
keymap/registry.go |
keymap.Entry |
keymap/describe.go |
keymap.Registry, New, Command, Bind, BindString, Unbind, Dispatch, Invoke, Seal, Attach, SetScreen, Has, Chords, Describe, Warnings, IsAttached |
keymap/registry.go |
termmosaic.Commandable |
widget.go, beside Focusable |
termmosaic.Clickable |
widget.go, beside Commandable |
KeyHint.SetEntries([]keymap.Entry) |
widgets/form/keyhint.go |
widget.go’s Handle doc comment, quoted in §2.3 |
widget.go |
vocabulary_test.go gains this ADR’s names |
vocabulary_test.go |
The Registry surface, in full, because a coder implements from this list:
// Registry maps gestures to named commands. The zero value is not usable;
// call New.
//
// A Registry is not safe for concurrent use. Commands' Run handlers may run on
// whatever goroutine Dispatch runs on, which for an application following the
// §2.2 loop is the event goroutine; mutations therefore follow ADR 0003's
// "mutate widget state only inside Post" rule, which covers the registry too.
type Registry struct{ /* unexported */ }
// New returns an empty Registry.
func New() *Registry
// Register adds commands, replacing any with the same ID.
func (r *Registry) Register(cmds ...Command)
// Command returns a registered command by ID.
func (r *Registry) Command(id CommandID) (Command, bool)
// Bind adds a binding. A zero Chord is ignored and reported by Warnings.
// Bindings added after Seal take the user-override rank in §2.1.
func (r *Registry) Bind(bs ...Binding)
// BindString parses s and binds it, reporting a parse error.
func (r *Registry) BindString(s string, id CommandID, scope Scope, owner termmosaic.Widget) error
// Unbind removes every binding for c in scope, including user overrides.
func (r *Registry) Unbind(c Chord, scope Scope, owner termmosaic.Widget)
// SetScreen declares the widget that owns the current ScopeScreen bindings.
func (r *Registry) SetScreen(w termmosaic.Widget)
// Attach pulls Bindings from every widget in ws that implements
// termmosaic.Commandable, registering each binding with ScopeFocus and Owner
// set to that widget. It is called when the tree is built and after any
// structural change; it is not called per frame.
//
// A widget that does not implement Commandable contributes nothing and is not
// an error.
func (r *Registry) Attach(ws ...termmosaic.Widget)
// IsAttached reports whether w was passed to Attach and is still current,
// which is how a rebuilt widget's stale bindings are detected (§5).
func (r *Registry) IsAttached(w termmosaic.Widget) bool
// Seal finalises the resolution tables. It is called by Attach and by Bind
// after the first Seal, and it is what makes Dispatch allocation-free: the
// candidate list per chord is built here and cached.
func (r *Registry) Seal()
// Dispatch resolves ev and runs the command it names, reporting whether the
// event was consumed. It is the §2 four-step path, in order, and it allocates
// zero times for every event kind.
//
// A non-key, non-mouse event — Resize, Paste, Focus, Compose — is never
// consumed and never dispatched (§2 step 1).
func (r *Registry) Dispatch(ev termmosaic.Event, focus termmosaic.Widget) (CommandID, bool)
// Invoke runs a command directly by name, bypassing Enabled and bypassing
// Dispatch. It is what a palette row, a menu item and application code call,
// and it reports whether the command was found — not whether it succeeded,
// because Run returns nothing and a declined Run is expressed by Enabled.
func (r *Registry) Invoke(id CommandID, ev termmosaic.Event, focus termmosaic.Widget) bool
// Has reports whether id is registered and currently available.
func (r *Registry) Has(id CommandID) bool
// Chords returns the chords bound to id in scope, in canonical order.
func (r *Registry) Chords(id CommandID, scope Scope) []Chord
// Describe returns the discoverable entries for scope, sorted by Group then ID.
func (r *Registry) Describe(scope Scope) []Entry
// Warnings returns the registry's diagnostics: a binding naming an
// unregistered command, a command with an empty Desc and not Hidden, a scoped
// binding with a nil Owner or a global binding with a non-nil one, a chord
// bound twice in one scope, and a binding whose owner is no longer attached.
// Diagnostic surface, never control flow — the stance input.Stats takes.
func (r *Registry) Warnings() []string
Deferred, with triggers. Stated as a block for the same reason ADR 0005 §10 states its deferrals.
| Deferred | Trigger to revisit |
|---|---|
Multi-stroke sequences and leader keys (g g, g p, <leader>) |
Two or more applications wanting a modal prefix scheme, or Key.F13–F35 landing, which is the same pending-sequence machinery. The reason it cannot simply be added: pending-sequence state belongs in input’s Parser, alongside the escape deadline it already owns (ADR 0005 §2). Putting it in keymap creates a second ESC-ambiguity policy and a second answer to “how long do we wait”. |
A command line (:write, argument-taking commands, Args []string on Command) |
An application with a real command vocabulary rather than a dozen named actions — a file browser, an editor. When it comes it is a KeyHint plus a TextInput plus a parser, and Command.Args is the last piece, not the first. |
| Loading and saving bindings from a file | The first application that needs it, and only after ScopeFocus bindings are enumerable, which requires the Attach-then-Describe order to be settled by real use. A loader that cannot enumerate what is bound cannot save it, and writing one earlier guarantees a rewrite. |
| Key release bindings | Never on our own initiative. KittyReportReleases is off by default (ADR 0005 §2) and no catalog widget consumes a release. Revisit if a drag-and-drop or key-up-driven widget ships. |
A DragCommandable interface, or any drag-as-command modelling |
The first widget that needs a drag gesture and wants it rebindable or palette-reachable — a resizable pane divider, a reorderable list. Handle already receives the events, so the trigger is a widget, not a user request. |
ScopeScreen as a distinct concept from “the root widget” |
The first application with two screens where only one is the tree root. SetScreen covers it in v0.2 without new vocabulary; the concept is deferred, not the capability. |
Making any catalog widget implement Commandable |
The first KeyHint in examples/ that needs its hints and the program’s bindings to agree. One widget, chosen because an example needs it — not all 24, and not for its own sake. |
form.Binding → keymap.Entry (the naming collision in §1) |
The next change to widgets/form that touches KeyHint. It is a breaking change to a shipped widget’s exported field, which is why it is not bundled into an ADR whose subject is commands. |
A Priority field on Binding |
Never on our own initiative. Numeric priority is what OpenTUI has and the reason this ADR specifies a fixed three-value order instead; a priority knob an application can set wrong produces bindings nobody can predict. Revisit only if a real application genuinely cannot express its layering with focus/screen/global, and then as a new ADR that also says how it interacts with user overrides. |
9. Relationship to the Menu and Dialog widgets, and to the command palette
The command palette is IN scope for v0.2, and it is not built by this ADR. It
is built on Menu and Dialog, which two other agents are writing right now,
and this section states exactly what it needs from them so that work is not
blocked and this ADR is not the thing that decides their API.
The decision. A Ctrl+K palette is a widget, not a mode. It is a Dialog
containing a Menu fed from Registry.Describe(ScopeGlobal) filtered by a
TextInput. Everything it needs from the command layer is three calls that
already exist in §4: Describe, Invoke, and Chords. Nothing about the palette
requires a new type in keymap.
What it needs from Menu:
| Requirement | Why |
|---|---|
| Items with a label, a dimmed right-aligned chord column, and a disabled state | A palette row is Entry.Desc plus Chords[0], and the disabled state is Entry’s command being unavailable. Without a distinct disabled state the palette must hide rows, and hiding them changes row count as the user types, which makes selection jump. |
SetItems that is allocation-free when the content is unchanged, or an explicit “same length, mutate in place” path |
The palette re-filters on every keystroke. If every keystroke rebuilds every row, the palette allocates on the input path, which is the thing ADR 0002’s bar exists to prevent. |
| Filtering done by the application, not the widget | Filtering is Describe plus a substring test over Desc and ID. It is not a widget concern, and putting it in Menu would make Menu know what a command is. |
No CommandID knowledge. Menu deals in rows; the application maps row index to Entry to CommandID. |
The alternative — Menu carrying command IDs — makes Menu depend on keymap, which makes every future list widget look like a palette. |
Esc reaching the application rather than being consumed |
ADR 0005 already has KeyEscape; a dialog that eats it would be unfocusable. |
What it needs from Dialog:
| Requirement | Why |
|---|---|
| Modal: keys do not reach the tree beneath | A palette that leaks j into a TextInput underneath is the classic palette bug. This is the whole reason the palette is a Dialog and not a Menu placed in the layout. |
| Focus save and restore | Opening the palette must not lose the focused widget. Registry.SetScreen then makes the palette’s ScopeScreen bindings live, and restoring focus makes the previous ones live again — which is the mechanism working as designed rather than a special case. |
Enter activating the selected row |
One Invoke, per §4. |
A minimum size, via the existing termmosaic.Minimizable |
A palette at 20 rows in an 8-row terminal is a clipped lie. ADR 0007 §“degenerate sizes” applies unchanged: clip, never blank, never panic. |
And the honest dependency statement. If Menu or Dialog does not land with
the rows above, the palette is blocked, not compromised — and the fallback is
a List in a Block with a TextInput, which the v0.2 catalog already has. The
palette is not the reason to change either widget’s API, and this ADR does not
change it.
Dialog and commands, one rule. A dialog’s confirm-and-cancel are commands
with ScopeScreen bindings, registered against the dialog widget as Owner. They
are therefore live exactly while SetScreen points at the dialog, which is what
makes “a dialog’s Esc cannot be stolen by a global binding” true by
construction rather than by convention (§2.1).
Forced changes to existing code
| Identifier | Current | After this ADR |
|---|---|---|
termmosaic.Widget |
Bounds/Draw/Invalidate/Handle |
unchanged. No method added, changed or deprecated. The Handle doc comment gains the precedence paragraph quoted in §2.3. |
termmosaic.Event, EventKind |
the ADR 0005 §8 union | unchanged. A command is resolved from an event and never carried inside one; no payload is added, so the unsafe.Sizeof(Event{}) guard test is untouched. |
termmosaic.Key, KeyMod, Mouse, MouseAction |
as decoded | unchanged. Chord is a new type over them; Key’s iota block is not extended, so the append-at-the-end rule (ADR 0005 §10) still governs KeyF13+ and the notation table picks them up automatically. |
widget.go |
Focusable, Minimizable |
gains Commandable and Clickable, both optional, both in the same file beside their siblings, both with the same “a widget that does not implement it is fully supported” contract. |
input package |
decoder, Parser, Source |
unchanged. keymap consumes Event; nothing is decoded here, and pending-sequence state stays in Parser (§8). |
widgets/form/keyhint.go |
Bindings []Binding, SetBindings |
gains SetEntries([]keymap.Entry). Bindings []form.Binding and SetBindings are unchanged, so the three in-flight widget agents are unaffected. form.Binding is not renamed in v0.2 (§8). |
vocabulary_test.go |
ADR 0007/0008 name list | gains this ADR’s reserved names — see the list below — and keymap/*.go plus widget.go as the declaring files. |
The reserved-name list to add to forbiddenDecls, and the reason each is
worth reserving rather than merely documenting:
// ADR 0009: the command layer. Chord, Command and Binding are the three a
// widget is most likely to define privately — "chord" for its own key
// matching, "command" for its own button click, "binding" for its own row — and
// a widget that defines one has a private version of the framework's, which is
// the exact failure ADR 0007 §1 rule 4 exists to prevent.
"Command", "CommandID", "Ctx", "Scope", "ScopeGlobal", "ScopeScreen", "ScopeFocus",
"Chord", "ParseChord", "ChordOf", "Binding", "Entry", "Registry",
"Commandable", "Clickable",
forbiddenDecls already contains Region, Priority and Budget from ADR 0007
and Style, Span, Wrap, Truncate and BorderStyle from ADR 0008, and the
scan is by top-level declaration name across every .go file in the module, so
this is a list edit and nothing else.
One collision the coder must be aware of and must not resolve by renaming:
form.Binding already exists and means something else (a display pair: a key
label and a description). keymap.Binding means a chord reaching a command.
They are different types in different packages with no assignability between
them, which is the ADR 0008 collision shape. It is
accepted in v0.2 because the alternative — renaming a shipped widget’s
exported type in the same change — is worse, and it is reconciled with a
trigger in §8. keymap.Entry is named to be the future owner of form.Binding’s
job, which is why the palette and help consume Entry and never Binding.
Consequences
Good
- A key meaning two things is now one thing.
form.activateKey’s hand-written “Enter or space” workaround, and the identical comment inKeyHint, become a property ofChord. The next widget does not re-derive it and cannot get it wrong on the terminal that disagrees. - Help cannot drift from the bindings, because
Describeis computed from the same tablesDispatchwalks. A renamed command updates help because there is only one place its name is written. Widgetis untouched, and so is every widget’s behaviour. 24 signatures,render’s dispatch and three examples’ event loops survive unchanged, and a text field keeps receiving undeclared printable runes and whole pastes exactly as ADR 0005 §4 requires.- Dispatch is 0 allocs, pinned by a test. The
Chordis a comparable 16-byte struct, the candidate list is built atSeal, andCtxis passed by value — so the bar ADR 0002 set for the diff is met on the input path rather than quietly dropped one layer above it. - Rebinding is three calls, and it composes with the palette because both go
through
Describe. An application can ship a rebinding UI without the framework knowing what a rebinding UI is. - A click is a command without a second source of truth for geometry. The registry holds no rectangles, so nothing in it can go stale on resize — the failure mode ADR 0007 §3 names, avoided structurally rather than with a cache key.
- The palette needs nothing new from
keymap, so it cannot block the Menu and Dialog work, and it cannot force an API change on either.
Bad — stated plainly
- Two mechanisms can answer one key, and the winner is the keymap. An
application that binds
qglobally and also has aswitchcase forqin a widget will find the widget’s case unreachable for the focused context. TheHandledoc paragraph and §2.3 are the mitigation, and they are documentation. This is the sharpest edge in this ADR and the one a reviewer should push back on first. - The catalog advertises nothing. No widget in v0.2 implements
Commandable, soexamples/dashboard’s/,q,Tab,Escand its entire search mode are invisible toDescribeand would be absent from a help screen.KeyHintremains the way a widget advertises, and the trigger to fix this is in §8. A help screen that ships before an application registers its commands will look empty, and that is correct but disappointing. Owneris a pointer, so a rebuilt widget’s bindings die silently. A widget reconstructed each frame stops matching itsScopeFocusbindings with no error.Warnings()andIsAttacheddetect it; neither prevents it, and the failure mode is “my key stopped working”, which is the worst kind of report.- Ctrl+M is Enter, and Ctrl+I is Tab, on a legacy encoding.
Chordfolds what the decoder folds, so a user who bindsCtrl+Mon kitty and then runs on a terminal without disambiguation gets Enter. This is ADR 0005’s accepted limitation inherited, not a new one, and the alternative — inventing a chord the decoder cannot produce — would be worse. Run func(Ctx)returns nothing. A command cannot report failure, so “did it work” is answered by inspecting state afterwards, andInvokereturns “found”, not “succeeded”. Aboolreturn was rejected because two different failure modes (unavailable vs declined) already exist and a third return value would make the caller handle three.- A user override does not beat a more specific scope. A user who binds
Escto quit globally will find every dialog’sEscstill cancels the dialog. This is §2.1’s central decision and it will surprise somebody. Describeallocates, and nothing stops a coder calling it fromDraw. It is documentation, not a type — the identical hazard ADR 0007 §7 names forBudgetand ADR 0008 names forWrap. The mitigation is the same shape: the allocation test coversDispatch, and the doc comment on every allocating method says not to.- No mouse gestures. Drag, double-click and right-click-as-distinct-from-a
modified-left-click are all out. The first two need data
Mousedoes not carry (a click count) or a gesture recogniser that belongs in the widget, and the third is expressible but unimplemented. - The palette is blocked on Menu and Dialog. If they do not land with the rows
in §9, the palette does not ship in v0.2, and “full input support” is delivered
without one of its most visible pieces. The
List-in-a-Blockfallback exists but is not what the palette should be.
Rejected alternatives, specifically
- Changing
Widget.Handleto take a command context or aCommandID. 24 signatures plusrender, plus it does not help widgets (aList’s paging handler needs its own viewport), plus it breaksTextInput(undeclared runes, whole pastes) and mouse hit-testing. Costed in §2.3 and rejected there. - A fifth mandatory
Widgetmethod (Commands() []Binding, orBindings()). ADR 0007 §“Rejected” already rejected a fourth mandatory method for the same reason — a method a widget can forget is a silent-failure surface, and 24 widgets would be forced to implement one they cannot usefully implement. Optional interface instead, with zero catalog widgets required. Commandablepolled on the dispatch path (ask each widget “do you want this key?”). An interface call per widget per keystroke, an allocation for the returned slice, and a per-widget ordering question with no principled answer. Bindings are pulled atAttach/Sealinstead.- Numeric
Priorityon bindings, as OpenTUI has. A knob an application can set wrong, producing bindings nobody can predict or debug by reading. Fixed specificity order instead; a trigger for revisiting is in §8. fallthroughandpreventDefaultflags on bindings, as OpenTUI has. Two mechanisms for one concept where one of them is a Go return value.RunreturningfalseandEnabledreturningfalseare the whole of it.- A binding that holds a
buffer.Rect, so a click region is a global table entry. A second source of truth for geometry that goes stale on the first reflow — ADR 0007 §1 rule 1’s exact failure.Clickableasks the widget, which is the thing that knows. - Synthesising a
Dragcommand fromMouseDragmotion. Fires on the first moved cell; the user gets no drag and the release is unhandled. - Multi-stroke sequences (
g g, leader keys) in v0.2. Pending-sequence state belongs toinput.Parser, beside the escape deadline it already owns; a second implementation inkeymapis a second ESC-ambiguity policy. Deferred with a trigger. :commandparsing andCommand.Args []stringin v0.2. A command line is a sub-language — parsing, completion, quoting, a text-input mode.Argsfirst would scatter that parsing across every handler.- A config-file loader for bindings in v0.2.
ScopeFocusbindings cannot be enumerated untilAttach-then-Describehas settled under real use, and a loader that cannot enumerate what is bound cannot save it. The API is sufficient for an application to write its own, which is the right owner for a file format anyway. - Key release bindings.
KittyReportReleasesis off by default and nothing consumes a release. Revisit when a widget does. - Merging
ChordintoEventas extra fields.Eventis a hot-path struct with a pinnedunsafe.Sizeofguard test, andChordis derived data — a printable rune is the gesture, andShiftis already folded into the rune by the decoder. Putting it inEventwould grow 112 bytes for a field nothing reads at decode time. - A
keymaptype namedBinding. It is the obvious name and the collision rule says no:form.Bindingalready exists and means a display pair. Naming itBindinginkeymapwould leave the tree with two exported types of one name and no assignability between them — the exactansi.Stylecollision ADR 0008 removed.Entryis the discoverability type andBindingis the reachability type, andform.Binding’s reconciliation is deferred with a trigger. - A framework-supplied palette mode inside
keymap. It would make the command layer own a focus stack, a text input, a filter and a row cursor. All four belong to widgets, two of which already exist or are being built.
Risks to revisit at v1.0
- The two-mechanism overlap (§Consequences) has never been observed under
load. The failure — a key bound globally shadowing a widget’s
switch— is reasoned about here and not measured, because no application in the tree mixes both. Trigger: the first application that does. The fix, if it bites, isWarnings()reporting a chord that is both bound and handled by an attached widget, not aWidgetchange. Partly observed, and the observed half is the narrow one. Two of four example programs now dispatch through a registry, andexamples/searchis the first with a focusable widget in a focus ring — aTextInputand aTable, withTab/Backtabmoving between them. So the two mechanisms are now in the same application, which is the trigger having fired. What it has not shown is the failure this risk is about:searchusesCommand.Enabledfor every context-dependent binding and declines to bind a single chord a focused widget wants —Home/Endare left to the widgets precisely because aScopeScreenbinding outranks both — so itsHandleand its bindings never compete for the same key, andTestNoScreenBindingStealsAFocusedWidgetsKeypins that. It has established that an application can mix them safely by construction; it has not observed what happens when one is built without that discipline.Registry.SetFocusremains deferred, andsearch’s workaround — tracking focus itself and filtering its hint onkm.Has— depends on nothing that closure would provide. The risk stays open, and so doesWarnings(). Chordnormalisation has never been run against a matrix of terminals. Every folding rule in §3 is derived from ADR 0005 andinput’s own tests, none of which have met a real tty — the risk ADR 0005 §“Risks” item 1 already records. Trigger: the same recorded byte-stream matrix ADR 0005 asks for, extended to assertChordOf(Decode(...))over it.Chord’s 16 bytes and the 0-allocDispatchare specified, not measured.TestDispatchIsZeroAllocationandTestChordIsSixteenBytesare the pinned form, in the spirit of ADR 0002 and ADR 0005. If a futureCommandfield pushesCtxpast a register set, the honest fix is a benchmark plus a re-measurement, not a comment.- Scope specificity may be too coarse for one real application. Three
contexts and no numeric priority is a strong opinion. Trigger: an application
that genuinely cannot express its layering — the signal is writing
if scope == ...inside a handler, which is the shape a missing fourth scope takes. - Nothing in the widget catalog implements
Commandable, so the discoverability story is untested at scale.KeyHint.SetEntriesis the one bridge and no example uses it yet. Trigger: the firstexamples/change that wants a hint line to agree with a binding; that example is the honest test ofDescribe’s sorting and of whetherEntry.Chordsas one-row-per-chord is the right shape. Triggered, and the answer to its own question is NO — one-row-per-chord is the wrong shape for a hint line. See the second 2026-10-05 amendment at the end of this document. The trigger’s other half, no catalog widget implementingCommandable, is still open and still deferred by §8. The wrong shape was wrong twice, in the same way, in a second application.examples/searchalso wanted its hint line to agree with its bindings, andKeyHint.SetEntriesis now called by two example programs (examples/hello/main.go,examples/search/search.go), both throughDescribeGrouped. What the two together establish is stronger than the first did alone, and stronger than “the interfaces are unused”: an application can have a fully registry-derived key contract — every chord, every command, and every hint row written in one place — withCommandableunimplemented and unimplemented-by-choice, becauseEnabledis a property of the command rather than of a widget. That is a materially better answer to this risk than the interface simply having no users: the widget-participation design is no longer carrying the discoverability story on its own, so §8’s deferral is not blocking.Clickableremains unused. What is still missing here isRegistry.SetFocus(deferred to v1.1), whichsearchworks around — see the 2026-10-05 amendment below. - The palette’s requirements on
MenuandDialogare stated here and implemented there. If either ships without them, the mismatch is discovered at integration rather than here. Trigger: the palette’s own PR, which is where §9’s table must be checked line by line.
Amendment 2026-10-05 — implemented, and five places the prose was wrong
The keymap package was built in v0.6.0. Building it turned up five points
where this ADR’s prose contradicted the code it specified. All five were found
while writing the implementation, reported rather than worked around, and fixed
at the source. Original reasoning is preserved above; this section records what
changed and why. The spec and its errata are deliberately in one document, so
the next reader sees both.
1. Commandable and Clickable live in keymap, not in widget.go. §1’s
file map and §“Forced changes to existing code” both put them in the root
package beside Focusable and Minimizable, which is where they read most
naturally. That is the one arrangement §1’s own import-cycle rule forbids: a
root-package interface named for the keymap cannot name keymap.Ctx — or
keymap.Scope, or keymap.Chord — without the root package importing the
package that imports it. The two interfaces are therefore declared in
keymap/participation.go. The consequence for an adopter is nil: a widget
declares var _ keymap.Commandable = (*MyWidget)(nil) and the cycle never
appears in their code. What the root package keeps is its unchanged Widget.
2. Ctx is 152 bytes, not 128. The field list above — Event (ADR 0005’s
112 bytes), Chord (16), Focus (16), Synthesised (bool) — sums to 145, and
Go pads the struct to the 8-byte alignment its largest field requires: 152. The
prose figure was an estimate written before the fields were fixed, and it was
optimistic. TestCtxIsOneHundredFiftyTwoBytes pins the real size. The argument
it supports is unaffected: this is a value on a path that runs at most a few
hundred times per second, and the property that matters is 0 allocations,
which TestDispatchIsZeroAllocation pins. Passing *Ctx would put an escaping
pointer on that path and turn a stack copy into a heap object per keystroke,
which is the reason the ADR gives for by-value in the first place.
3. §2.1’s precedence table contradicted itself about overrides, and
specificity wins. The table’s rank 1 reads “User override, any scope” and
says it “outranks a default in the same scope”, then goes on to claim a
ScopeScreen override outranks a ScopeGlobal default. Those two statements
describe two different rules: the first is rank-above-everything-within-a-scope,
the second is specificity-first. Under the first reading, a global Bind of
Esc would outrank the dialog’s own Esc — which is the exact failure the very
next paragraph argues against. The shipped rule resolves the contradiction the
way the paragraph does: scope specificity orders candidates first, and an
override is the within-scope tiebreak. So a user’s app.cancel on Esc wins
in ScopeGlobal, and a screen’s Esc still wins over it. The paragraph was
right and the row was wrong.
4. §3’s notation table and §3’s normalisation disagreed about Ctrl+k versus
Ctrl+K. The notation prose says modifier names are matched
case-insensitively, “which is what makes ctrl+k and Ctrl+K the same
binding” — while the written-form table lists only Ctrl+K and its canonical
form, leaving the reader to guess. They are two distinct chords, and
ParseChord accepts either spelling. The case is the key, not the modifier:
the terminal reports Shift+k as ModShift plus the rune K, which is a
different Chord from Ctrl plus the rune k, and folding them would make a
documented binding unreachable on the terminal that produces it. Modifier names
themselves (ctrl, Ctrl, CTRL) are case-insensitive on parse and emitted
capitalised. The table now says so where a reader will look.
5. Entry.Chords has length 1 in Describe output, not “every chord bound to
this command”. §4 describes Chords as “the chords currently bound to id in
scope, in canonical order”, which reads as a slice that can hold several. What
ships is one Entry per chord: Describe walks the resolved binding table
and emits a separate row for each chord, each with a single-element Chords.
That shape is what §9’s palette wants — “a palette row is Entry.Desc plus
Chords[0]” — and it is what makes a per-chord help row impossible to
mis-read, at the cost of a command with three bindings producing three rows
rather than one row with three chords. Both are defensible; the shipped one is
pinned by keymap/describe_test.go. The field’s type stays []Chord because
that is what a KeyHint row and a palette row both consume.
Amendment 2026-10-05 — risk 5 is triggered, and it is a NO
The first amendment above ended with erratum 5, which recorded that Describe
emits one Entry per chord and defended that shape as the right one for a
palette. This one closes risk 5, the last of the six, and the trigger it named
has now fired: examples/hello is the first change that wanted a hint line to
agree with a binding. It dispatches through a real keymap.Registry — six
commands, twelve chords — and renders both its pinned hint line and its ?
overlay from the registry through KeyHint.SetEntries.
The honest answer to risk 5’s own question is no: Entry.Chords as
one-row-per-chord is the wrong shape for a hint line. Not wrong in principle
and wrong only for palettes — wrong for hints, specifically and for a reason
that is visible in one line of a terminal. SetEntries joins an entry’s chords
into one label, so handing it Describe’s output renders a command with
three chords as the same description three times. That is not a hint, it is
three rows of noise in a line with room for one. examples/hello worked around
it with a thirteen-line merge of its own before this amendment existed; the
workaround is now a framework function and is deleted.
The consequence is a second query rather than a change to the first.
Registry.DescribeGrouped(scope) returns one Entry per command, carrying
every chord in scope for it in canonical order. Describe is unchanged and
remains one row per chord, which is what §9 specifies for a palette — “a palette
row is Entry.Desc plus Chords[0]” — and what a help screen wants, because one
row per chord is easier to scan. A two-function API is the price of two genuinely
different consumers, and a hint and a palette are genuinely different consumers.
The two are kept honest by construction rather than by convention: both are
views of one internal describeRows, so they cannot disagree about which
bindings are in scope, about the order, or about which description a binding
overrides. DescribeGrouped merges Describe’s already-sorted rows rather than
re-sorting, so its entry order and chord order cannot drift from Describe’s
either.
Two rows of DescribeGrouped are specified rather than incidental, and both
are the hint half of the difference. Chords is never empty: a command
bound in no scope is absent rather than present with no chords, because a row
with an empty key column renders as a bare description with nothing to press,
and a hint whose entries are mostly bare descriptions has stopped being a hint.
Describe keeps those rows — a palette-only or mouse-only command must stay
describable, and a key-only help screen that hid it would be wrong. And entry
order matches Describe’s command order exactly, so a hint’s rows are stable
across runs and diffable across versions.
examples/hello is what the discoverability story is now tested against, and
the visible consequences are recorded here because they are what a reader of the
golden files will notice. The pinned hint reads
[Q q Esc Ctrl+c] quit · [?] toggle the keys, where it read
press q to quit · ? keys · arrows move focus; the navigation is gone from
the one-line hint because a merged row per navigation command is wider than the
line, and a hint that truncates a binding label tells the reader less than a
hint naming the two keys they need first. The help overlay went from two rows
to three, because a derived help spells every chord in full where the prose
help it replaced said “arrows”. Seventeen golden files moved, one row each. The
hand-written hint string and the test that checked it against Handle are both
deleted: a binding and its description are now written once.
What risk 5 does not close. Two items survive it, and both are named here rather than left to be rediscovered.
No catalog widget implements Commandable, and still none implements
Clickable. Verified: grep -rn 'Commandable' widgets/ returns nothing. The
example that triggered this trigger is an application opting in, which is not
the same thing as a widget contributing its own bindings, so §8’s deferral is
untouched and examples/hello deliberately does not implement either — its
facts are cells it draws, not widgets with bindings of their own.
Registry has no SetFocus, so Describe(ScopeFocus) is incomplete before
the first dispatch. The registry learns what is focused only from the focus
argument Dispatch is handed, so a focus-level query asked before anything has
been dispatched answers as though nothing holds focus. That is not a
correctness bug for a palette — a palette asks ScopeGlobal, and the ADR’s §2.1
specificity order is what makes a focused widget’s own keys win over a global
one anyway. It is a gap for the question §4 actually asks, “what can I do right
now”: an application with real focusable widgets cannot yet ask it. Adding
SetFocus is new API on a shipped type, so it is deferred to v1.1.
examples/hello sidesteps it rather than depending on it — its navigation is at
ScopeScreen, which is also the scope that degrades correctly: a focused child
added later binds its own arrows at ScopeFocus and outranks them by
specificity, with no change to the example.
Amendment 2026-10-05 — examples/search: SetFocus is evidence, not a hypothetical
The second amendment above recorded that Registry.SetFocus is deferred to
v1.1, on the reasoning that an application with real focusable widgets cannot
ask “what can I do right now”. That reasoning was sound and it was untested.
examples/search is the application that tests it, and it is the first example
with a Focusable widget in a focus ring — form.TextInput and data.Table,
moved with Tab/Backtab, drawn as a visible ring. It reached three findings,
and none of them closes the deferral.
A deferral reasoned from “no application needs this yet” is now a deferral
reasoned from an application that wanted it and could not have it. search
tracks focus itself and filters its hint on km.Has — “registered and currently
available” — because Has is the only predicate that consults the same liveness
Dispatch does. The query an application makes when focus changes is exactly the
one the registry cannot answer. That is stronger evidence for the v1.1 item than
the argument that preceded it, and it is also why the item stays open: the
workaround is eight lines an application has to write correctly on its own.
Describe(ScopeFocus) is not incomplete — it is over-inclusive. inScope
returns true on an exact-scope match without consulting liveness at all, so a
focus-scoped query answers “these bindings are in scope” for a binding whose
command is currently disabled. Applied to a hint, that advertises the field’s
arrow bindings on a screen where the table has focus and those arrows move a
selection: a hint wrong in exactly the pane the reader is looking at. search
reaches for neither ScopeFocus nor the question, and says so in its own source.
Context-dependence does not need a fourth scope. Every binding in search is
ScopeScreen or ScopeGlobal, and every context-dependent one is gated with
Command.Enabled — which dispatchChord skips, reporting the event unconsumed,
so the tree gets it. That is the framework’s own fallthrough, it is exact, and it
has a consequence worth recording against risk 1: because a screen-scoped
binding outranks a focused child, the shape is only safe if the application
declines to bind chords its focusable widgets want. search leaves Home/End
to the widgets and binds the ring’s ends to Ctrl+Home/Ctrl+End instead. The
overlap is now demonstrated as avoidable by discipline; it is still not
demonstrated as handled by the framework, and Warnings() is still the fix if
it is not.
SetEntries has two consumers now, not one. examples/hello and
examples/search both build their KeyHint from DescribeGrouped, and the
answer to risk 5 was the same both times — one Entry per chord renders a
multi-chord command’s description once per chord. No catalog widget implements
Commandable or Clickable, and §8’s deferral of the first is unchanged.