Actions and reserved keys — design¶
Status: implemented, except pkg/keys and rule 31, which are independent
and still proposed. pkg/action, geom.AnchorIn, mouse.IsRightPress /
IsPointPress, theme.Actions(), marking in pkg/list and pkg/table,
runner.Go / runner correlation tags, the app shell wiring
(Options.ActionsKey, the overlay routing mode, right-click, the statusbar
receipt), examples/patterns/actions and examples/patterns/multiselect are all
in. Marking is demonstrated in examples/patterns/multiselect and summarised
as CLAUDE.md rule 32 — rules 30 and 31 below are still unwritten, so the
marking rule took the next free number rather than one they had
reserved. examples/patterns/actions stays single-target on purpose.
Decisions 6, 7, 15 and 19
record where the built thing corrected the design.
Problem¶
Three complaints, one shape underneath.
1. Feedback is inconsistent, because only two kinds of work have a channel.
pkg/output solved this for the two things that already flowed through the
shell: app.Info/app.Error and runner.Capture. Everything else — an HTTP
POST, a k8s API call, a file write, anything that is Go work rather than a
subprocess — has no channel at all. The author picks between app.Info (one
line, wiped by the next keypress), an alert modal (breaks flow for a success
notice), or nothing. Nothing wins, because nothing is the least work. So the
console is excellent at reporting the two things it was wired to and silent
about the verbs an app actually exists to perform.
2. Nothing pushes an author onto the async path. Update has a value
receiver and returns a tea.Cmd; the correct shape is right there. But
writing the blocking version is fewer characters — call the API, get the
result, set the field — and it freezes the TUI for the duration with no
warning at authoring time. The library offers runner.Capture for
subprocesses and, for everything else, a recipe.
3. The footer is full, and rule 8 has no mechanism. Rule 8 already says
per-screen verbs should be menu-driven rather than letter shortcuts. But the
only menu the library ships is "build a list.Model and push it as a screen,"
which costs a breadcrumb crumb and a round trip through OnEnter — so authors
bind letters instead. Then they collide, because nothing in the library states
which letters are already spoken for. Rule 25 reserves the scroll vocabulary
in prose; the rest is folklore recoverable only by grepping every
DefaultKeys() in the tree.
The three are the same problem seen from different sides: the library has no concept of an action. It has components that display things and a shell that routes keys, and the verb in between is left as an exercise.
Shape¶
An Action is a named verb over the current selection. A screen declares the
ones that apply right now; the shell owns the key, the right-click, the menu
overlay, the goroutine, the cancellation, and the logging. The author writes a
label and a function.
func (s *Screen) Actions() action.Set {
// Selection is the marked set, or the cursor row when nothing is marked.
targets := s.table.Selection()
if len(targets) == 0 {
return action.Set{}
}
return action.Set{
Target: s.table.SelectionLabel(), // "cache-redis" or "3 items"
Actions: []action.Action{
{
Label: "Restart",
Multi: true, // three marked pods, three restarts
Exclusive: true, // but not twice on the same one
Confirm: fmt.Sprintf("Restart %s?", s.table.SelectionLabel()),
Run: func(ctx context.Context, out io.Writer) error {
for _, name := range targets {
fmt.Fprintf(out, "restarting %s\n", name)
if err := s.api.Restart(ctx, name); err != nil {
return err
}
}
return nil
},
},
{
// No Multi: there is one screen to push, so the menu disables
// this by itself the moment a second row is marked.
Label: "Logs",
Do: func() tea.Cmd { return screen.Push(newLogScreen(targets[0])) },
},
{
Label: "Delete",
Multi: true,
Disabled: guard(targets),
Confirm: fmt.Sprintf("Delete %s? This cannot be undone.", s.table.SelectionLabel()),
Run: func(ctx context.Context, out io.Writer) error { … },
},
},
}
}
Mark rows with space, then press the actions key or right-click: a bordered
menu opens anchored where you asked for it, titled Actions · 3 items. Enter
picks one; the menu closes immediately; Restart runs in a goroutine with a
cancellable context; every line it writes lands in the console attributed to
Restart, grouped as one event, killable with x like any capture; the
outcome paints the statusbar. The TUI never blocked.
Plus pkg/keys: a registry of what the library has claimed, so "which letters
may I bind" has an answer, and a test that keeps the answer true.
Decisions¶
1. The screen declares actions; components do not.¶
An action is a domain verb — "restart deployment", "cancel run", "promote". A
list.Model is a generic renderer of strings; it has no domain and cannot
acquire one without dragging the caller's types into pkg/list. Hanging an
action registry off Options would also mean a screen with three components
wires the same closures into three places and then reconciles which one is
focused anyway.
The screen already holds the answer to both questions the menu needs — which
component has focus (focus.Group.Is) and what is selected in it — so the
scoping the user wants ("actions for this component") is a switch the screen
writes once:
func (s *Screen) Actions() action.Set {
switch {
case s.focus.Is(&s.deployments):
return s.deploymentActions()
case s.focus.Is(&s.events):
return s.eventActions()
}
return action.Set{}
}
This also matches rule 7: parent → child data goes through construction, child
→ parent through Pop, and nothing reaches around the stack. An action
registry living on components would be the first thing in the library that
does.
Rejected: components contribute built-in actions (copy value, expand all,
open link). Tempting, and it would make the menu non-empty for free — but it
refills the menu with library verbs, which is the clutter problem relocated.
The one exception worth revisiting later is pkg/table's hyperlink cells,
where "open in browser" is genuinely component knowledge (ansi.ExtractHyperlink
already exists for it). Deferred, not refused.
2. An optional interface, not a change to screen.Screen.¶
// in pkg/action
type Provider interface {
Actions() Set
}
// Set is a screen's actions plus what they will act on.
type Set struct {
// Target names the object of the verbs — "cache-redis", "3 items".
// It titles the menu and it is the whole reason Set is a struct rather
// than a bare slice: once marking exists (decision 19), "Delete" can
// mean one row or twelve, and the menu is the last surface that can say
// which before it happens.
Target string
// Count is how many targets Actions will act on. 0 and 1 both mean a
// single target; above that, actions without Multi are disabled (21).
Count int
Actions []Action
}
Later: Targets (the keys) was added beside Count, and the two could
disagree. A Set with Targets but no Count read as one target, so the Multi
gate never fired. The menu now gates on Set.Arity(), which is len(Targets)
when there are any and Count otherwise, and Validate flags a Count that
contradicts Targets.
The shell type-asserts the active screen. Every existing screen keeps
compiling and simply has no actions, which is the honest state of affairs for
a screen that hasn't declared any. focus.Capturer is the precedent: an
optional interface answering a shell question that most implementations don't
need.
Actions() is called on menu open, on key dispatch, and on every apply()
that needs to know whether to advertise the key — i.e. on roughly the same
cadence as Help(), which screens already rebuild per message. Same contract:
cheap, allocation-light, no I/O. Anything expensive belongs behind a field
the screen already maintains.
3. The menu is an overlay the shell composites, not a pushed screen.¶
pkg/output chose a pushed screen and named the cost: a breadcrumb crumb for
a place the user didn't navigate to. For a console that's a fair trade — you
go there and read for a while. For a context menu it is the wrong trade three
times over:
- The row you are acting on scrolls out of view at the moment you choose the verb that acts on it.
- Popping fires
OnEnteron the screen underneath, so the menu would need its own sentinel and every fetching screen would need a second guard branch next to theOutputClosedone. One sentinel per transient surface does not scale. - A crumb reading
Deployments › Actionsdescribes a location. The menu is not a location.
So the shell draws it as ZStack(body, at(x, y, …)) inside its own View, and
app.Update grows a routing mode: while the menu is up it takes every
tea.KeyMsg and the active screen sees none.
That new routing mode is the real cost of this decision, and it is the
thing the output console was designed to avoid. It is accepted here because
the alternative costs more, and because the mode is small and total — the menu
owns the keyboard completely while it is up, exactly as pkg/confirm owns it
inside a host screen, so there is no partial-routing state to reason about.
Mouse still fans out to everything (rule 6): a click outside the menu closes
it, and the component under the pointer is entitled to see that click.
4. The menu sizes and places itself; no new layout primitive.¶
An earlier draft proposed layout.At(x, y, w, h, child) as a positioned
counterpart to Center. It isn't needed. pkg/alert's autosize mode already
does exactly what a context menu needs, without a new node type:
SetRecttreats the rect it is handed as outer bounds rather than as the component's own box.- The component measures its content and computes its natural size against caps derived from those bounds.
- It places itself with
geom.CenterIn(r, w, h)and retains the placed rect, so it hit-tests where it actually landed rather than against the bounds it was given. Viewrenders the box padded to the outer bounds, so the host composes with a barelayout.Sized(&m)and nolayout.Centerwrapper.
The menu is that, with the anchor swapped from "centered" to "at this point,
clamped." That is one function in pkg/geom:
func AnchorIn(outer Rect, x, y, w, h int) Rect
Placing a w×h box with its top-left at (x, y), pushed back inside
outer when it would overflow — a menu opened on the bottom-right row flips
up and left instead of hanging off the edge. Eight lines in a leaf package
beats a new node type in pkg/layout, and it keeps rule 9's "a component owns
its geometry" intact: the menu decides where it lands and hit-tests where it
landed, exactly as alert already does.
5. Execution is func(ctx context.Context, out io.Writer) error.¶
type Func func(ctx context.Context, out io.Writer) error
Three deliberate choices in one signature.
ctx first, so cancellation is not optional. The console already has x
for killing an in-flight capture; an action that cannot be stopped would be a
second class of in-flight work with a worse story than subprocesses have.
io.Writer rather than a channel or a log(string) closure, because it
composes with everything that already writes. fmt.Fprintf works. io.Copy(out,
resp.Body) works. An action that shells out can point cmd.Stdout straight at
it. A logger can wrap it. A closure of our own invention composes with nothing
and would be re-wrapped at every call site. The writer splits on newlines
internally and pushes one record per line into the same bounded channel
runner.Capture uses, so backpressure behaves identically — a producer that
outruns the UI blocks rather than growing memory.
error return drives the finish level. No status enum, no completion
struct: an action either worked or it didn't, and the reason is an error.
ctx.Err() on cancellation reads as a failure, which is correct — the user
stopped it.
Rejected: Func returning a result value for the screen to consume. Two
channels back (log + typed result) doubles the protocol, and the case is
already served: an action that needs to hand data back returns a tea.Cmd via
Do and posts its own message, exactly as a fetch does today.
6. Background actions reuse the capture message family.¶
pkg/runner grows one function:
func Go(label string, fn func(ctx context.Context, out io.Writer) error) tea.Cmd
emitting the existing CaptureStarted → CapturedLine… → Captured
sequence, over the existing stream, with the existing bounded channel and
Next-driven read.
The payoff is that pkg/app needs no new logging code whatsoever. It
already appends a head record on CaptureStarted, registers the run so the
badge shows ⟳ and x can kill it, appends a continuation per line, and
posts a levelled completion on Captured. Point a Go func at that pipeline
and requirement 1 — every action reports through the console, consistently —
is satisfied by construction rather than by convention. Kill becomes
cancel() instead of killProcess(cmd), which the run registry already
models as an opaque kill func() error for precisely this reason.
pkg/runner stays dependency-free: context and io are stdlib, and the
messages stay neutral. Its package doc widens from "runs an interactive
subprocess" to "runs work whose output is captured rather than given the
terminal" — which is what Capture already meant.
One small addition is needed: CaptureStarted gains a Detail string for the
head line. Capture fills it with $ cmd args (what commandLine builds
today); Go fills it with the label. Without it the head for Go work renders
(no command), which is the shell's current fallback for a nil *exec.Cmd.
Two things the build added. GoOptions.Detail turned out to be needed
twice over, not once: a Go run has no Cmd to derive a head line from, and
the log's source column is ten cells wide, so an action label carrying its
target ("Restart · 2 items") truncates to Restart · there. Splitting them —
short Label for the source column, full Detail for the head line — is what
makes the log read properly, and it is not something the design anticipated.
The second is a recover around fn. A panic in background work would
otherwise kill the program and leave the run in flight forever, with the
badge showing ⟳ for something that can never finish; it is reported as the
failure it is instead.
Naming wart, named: a Captured message for a Go function reads oddly.
The alternative is renaming the family to runner.Started/Line/Done with
aliases, which churns a shipped API and every screen matching on it. The
reading that "capture" describes where the output goes — captured, rather
than handed to the terminal — holds for both producers, so the names stay.
7. The shell logs the invocation — for Do actions only.¶
Corrected during implementation. The decision originally said "whatever
the action does", and the built version did exactly that for one round of
testing, which is how the mistake surfaced: the badge read 2 output for a
single Restart.
A Run action already opens its own event — runner.Go emits a head record
carrying the action's Detail — so a separate invocation line makes every
action report twice in a badge whose whole premise is events, not lines
(rule 17). The original text even said the invocation line "is the head of the
run's event", which is true; it just isn't a second record.
So a Run action gets one head and its output beneath it:
14:23:01 INF Restart › Restart · 2 items
14:23:01 INF Restart │ restarting api-server
14:23:02 INF Restart │ api-server rolled out
A Do action gets the invocation line, because it is the only line the shell
can promise — the returned tea.Cmd might push a screen or hand the terminal
to $EDITOR, and the library cannot narrate what it does not own.
Attribution still works as decision 9 wanted: records from a background action are sourced to the action rather than to whatever screen the user wandered to by the time it finished.
This is what makes "all actions communicate through the console" true rather
than aspirational. It also fixes attribution for the Run path: decision 9 of
the output-log design accepted that app.Info cannot know who created it,
because a tea.Cmd cannot introspect its creator. An action can — the shell
launched it and knows its label — so every record from a background action is
correctly sourced even if the user has navigated three screens away by the
time it fails.
8. The outcome paints the statusbar; the detail stays in the log.¶
On Captured for an action-launched run, the shell already appends a levelled
completion record. It additionally sets the statusbar message — Restart
completed / Restart failed: <err> — which is InfoDetail semantics applied
automatically.
Every action produces exactly one statusbar flash and one console event. No
Quiet flag: an action that shouldn't tell the user it happened is not an
action, it's a keystroke, and rule 20 already covers keystrokes.
Corrected later: "completed" is not always true, so the text is the
author's. Captured means the action's function returned. For an action
that performs the work — a build, a file write — that is completion and the
default reads correctly. For one that dispatches work to a server it returns as
soon as the request is accepted, and "Sync completed" then claims something the
shell cannot know: the server may still be syncing, and with row activity
(docs/activity.md) the row is visibly still saying so while the receipt says
it is done. Action.Receipt overrides the success text — "Sync requested" is
true of both cases. The failure text is unaffected, since an error is an error
whenever it arrives.
Scoped to actions, not to every capture. The worry when this was written
was that painting the bar on runner.Captured would change behaviour for every
existing runner.Capture caller — a shipped feature with callers of its own.
It doesn't have to: an action the shell launched carries a correlation Tag,
and the receipt is scoped to runs that have one. A bare runner.Capture
behaves exactly as it always did.
That tag is the one addition pkg/runner needed for the shell. Neither Label
nor RunID could do the job — labels collide across targets, and the run ID is
minted inside the command, after the caller has returned — so a caller
correlating a finished run back to what it launched it for had no handle at
all. The shell uses it to release the right Exclusive gate as well.
9. Right-click focuses, moves the cursor, then opens the menu — in that order.¶
mouse.Msg gains IsRightPress(). Components treat a right press exactly as
they treat a left single press — take focus via focus.RequestSelf, move the
cursor to the row under the pointer — and never as an activation. The shell
forwards the event to the stack first, then opens the menu, so Actions()
is asked after the cursor has moved and reports on the row the user pointed
at, not the one that happened to be selected before.
No new bubbling mechanism is needed: the shell already forwards then acts, and components already decline events outside their rect.
The Tracker is untouched — it counts left presses only, and a right
double-click means nothing in this library.
Known limitation, and the reason the key is primary: several terminals
never deliver a right press to the application. macOS Terminal.app shows its
own context menu; iTerm2 and others forward it once mouse reporting is on, but
this is emulator policy, not something the library can fix. Mouse is opt-in
already (app.Options.Mouse), so right-click is an accelerator on top of an
accelerator. The keyboard path must be complete on its own, and the menu is
fully navigable with the list vocabulary users already know.
10. Opt-in via a single ActionsKey, conventionally a.¶
ActionsKey: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "actions")),
Following OutputKey exactly, and for the reason spelled out there: a letter
the shell claims is a letter taken from every component in every downstream
app, permanently. That should be a line an app author writes on purpose. a
is the recommended value and is otherwise unclaimed (see the registry below);
the library does not default to it.
The shell advertises the key itself rather than leaving it to screens — same
exception as OutputKey, same reasoning (rule 14): the key exists only in
apps that opted in, so screen authors have no reason to know about it, and
left to them it would be advertised on the one screen whose author remembered.
It is advertised only when the active screen actually has actions, and is
inert otherwise. The precedent is ?: helpOverflow gates both the
affordance and whether the key does anything, so the hint is the source of
truth. A key that opens an empty box teaches users the feature is broken.
11. An action may carry a key; Help() must not list them.¶
{Label: "Restart", Key: key.NewBinding(key.WithKeys("r"), …), Run: …}
The shell dispatches it when the screen is not capturing keys, so declaring an
action gets you both the menu entry and the shortcut from one declaration —
the screen's Update grows no branch per verb.
The menu renders the key in a right-hand column, and that is the only place
it is advertised. This is the whole answer to the footer-clutter complaint:
discovery moves from a strip that must fit one row into a surface built to
hold a list. A screen with nine verbs contributes one binding to Help(), not
nine.
Action keys are checked against the registry and dropped if reserved. The
shell dispatches action keys before forwarding to the screen, so an action
bound to j would silently kill scrolling on that screen. Rather than let
that ship, a reserved binding is ignored for dispatch and the menu shows the
action with no shortcut. action.Validate([]Action) []error makes it a test
failure instead of a mystery — the repo's own lesson (see the anti-patterns on
internal/componenttest) is that documentation does not prevent this and a
test does.
12. Disabled actions are shown with a reason, not hidden.¶
{Label: "Delete", Disabled: "pod is already terminating"}
Rendered dimmed, unselectable, reason in the row. Hiding it is worse: the user learns the verb doesn't exist rather than that it doesn't apply right now, and they go looking for it in the docs. The screen is still free to omit an action entirely when it is meaningless rather than merely unavailable.
13. Confirm for destructive verbs, using pkg/confirm.¶
A non-empty Confirm string puts the existing modal between the pick and the
run. It lives in the shell's overlay slot, replacing the menu, so the routing
mode already built in decision 3 covers it with no second mode. Without this
every app hand-rolls the same two-modal sequence, which the anti-patterns
section already forbids for good reason.
14. Run and Do are two fields, and exactly one may be set.¶
type Action struct {
Label, Desc string
Key key.Binding
Confirm string
// Disabled, when non-empty, renders the row dimmed and unselectable with
// this as the reason. The menu also sets it automatically — for a
// non-Multi action under a multi-selection (21), and for an Exclusive
// action already in flight (16).
Disabled string
// Multi accepts a selection of more than one. Defaults to one (21).
Multi bool
// Exclusive refuses a second concurrent run for the same target (16).
Exclusive bool
ID string // scopes Exclusive; defaults to Label
// Run is background work. Prefer it: the shell runs it under a
// cancellable context, streams what it writes into the console under
// Label as one event, and reports the outcome. Concurrent by default.
Run Func
// Do is the escape hatch for verbs that are not background work —
// pushing a child screen, handing the terminal to $EDITOR via
// runner.Run, opening a form.
//
// Navigational Do actions are one-at-a-time by construction rather than
// by declaration: pushing a screen replaces what is on top, so there is
// no second one to push and nothing for Exclusive to prevent. It is a Do
// that *doesn't* navigate — one that fires a request and returns — that
// may still want Exclusive.
Do func() tea.Cmd
}
A single Do func() tea.Cmd with an action.Background(…) helper was the
tidier-looking option, but it hides the difference that matters: Run gets
attribution, grouping, cancellation and automatic reporting, and Do gets
none of those because it cannot. Two fields put that choice in front of the
author at the moment they make it. Validate enforces the "exactly one".
Note that Run/Do and Multi/Exclusive are orthogonal, and it is
worth resisting the urge to collapse them. The temptation is to infer arity
and exclusivity from which function field is set — Do pushes a screen, so
surely it's single and exclusive? But a Do that opens a browser tab per
target is legitimately Multi, and a Run holding a cluster-wide lock is
legitimately Exclusive. Inferring either would be right most of the time,
which is the worst frequency for a wrong default: often enough to be relied
on, rare enough that the exception ships broken.
15. theme.Actions() — rule 3 holds here after all.¶
An earlier draft of this decision said the opposite: that pkg/action would
need OptionsFrom(t) because theme could not import it, the way
pkg/output cannot be imported by theme. That was pattern-matching, not
checking. There is no cycle.
pkg/output's problem is specific: output.Screen implements
screen.Screen, pkg/screen imports pkg/theme for SetTheme, so
theme → output closes the loop. action.Menu is a plain component, not a
screen — it imports pane, geom and mouse, none of which import theme —
so theme importing action closes nothing.
So the th.Component() convention of rule 3 applies unchanged, and
pkg/output remains the only place it bends. Worth recording because the
wrong version of this decision was written down confidently, and the check
that settled it took one grep of pkg/pane's imports.
The one visible consequence is in the tests: pkg/action's own test files
cannot import pkg/theme (that would be a cycle, in the test binary), so
they build Options locally. Which is the right dependency for a component
test to have anyway.
16. Concurrency is the default; Exclusive opts out, and the menu shows why.¶
Most background actions are safely concurrent: each is a run with its own ID, its own context, and its own line in the kill picker, and the badge already counts them. Firing three restarts and watching them interleave in the console is a feature.
But some verbs genuinely cannot overlap — a deploy, a migration, anything that takes a lock — and "the action guards itself" is the wrong answer for them, because the guard has to live in a closure that has no way to tell the user it declined. So:
// Exclusive refuses a second run while one is in flight for the same
// target. The menu renders it disabled with "already running" rather than
// silently dropping the press.
Exclusive bool
// ID scopes that check. Defaults to Label.
ID string
The identity is ID + Set.Target, which gives per-target exclusivity for
free: restarting web while api restarts is fine, restarting web twice is
not. The shell can answer this without new bookkeeping — output.Buffer
already holds the in-flight run registry that the badge's ⟳ and the x kill
picker read from.
Rendering it as a disabled row with a reason rather than a rejected keypress is the whole point. This supersedes an earlier draft of this decision, which said the library would not dedupe and left it to the author; that was written before decision 12 established that "visible but unavailable, with a reason" is a thing the menu can express. Once the menu can say "already running," declining to say it is just a worse product.
What the second press does is therefore nothing, visibly — not queue, not
cancel-and-restart. Those are policies a verb might want, and an author who
needs one writes it inside Run, where the domain knowledge is.
17. The menu is a dedicated component, not a configured list.Model.¶
The reflex is to reach for pkg/list — it already has a pane, a cursor, a
filter, and the nav vocabulary. It is the wrong shape, for one reason that
then produces several: a list item is a string, and a menu row has
structure. refresh() builds "▸ " + it and hands the pane one string, so
label / key-hint / disabled-reason have to be flattened into it before the
list ever sees them. That breaks twice:
- Right-aligning the key column needs the pane's inner width before the items exist — circular with decision 4, where the width is measured from the items.
- Dimming a disabled row needs ANSI inside the item, but the cursor row
gets
selectedStyle.Render(...)wrapped around the whole string, and a nested reset closes the outer style. Rule 19's hazard, relocated frompkg/tabletopkg/list.
And a disabled row must be visible but unselectable, which list has no
concept of — its cursor walks visible with no skip. Teaching it one puts a
field on every list in the library to serve a single caller.
So action.Menu owns a pane.Pane (rule 9) and renders its own rows, its own
cursor, and its own anchor. What it gives up is the embedded filter.Model,
which it re-adds when a menu grows long enough to want / — about thirty
lines, and the same thing list itself does.
This does not block the hosting decision. The component emits
ChosenMsg / CancelledMsg exactly as pkg/confirm does, so it can be
built, tested and demoed against a host screen (as examples/patterns/modals
already does) before anything in pkg/app changes.
9b. A right press points; only a left press acts.¶
Decision 9 said components should treat a right press "exactly as they treat a left single press". Implementing it showed that is too broad — the press branch of several components does more than point:
pkg/treeandpkg/inspectortoggle the▸/▾glyph on a single click.pkg/tablesorts when the press lands on a column header.pkg/listandpkg/tabletoggle a mark when it lands on the✓gutter.
All of those are acting, and a right-click that expands a branch or re-sorts
a table on its way to opening a menu is a surprise the user did not ask for.
So the rule sharpened into two verbs: mouse.IsPointPress (either button)
means "focus this and put the cursor here", and IsPress (left only) is what
any chrome that does something checks. Activation was already safe, since
IsDoubleClick implies IsPress.
pkg/toggle, pkg/confirm and pkg/alert were left untouched for the same
reason: every press they handle is an action, so a right-click on them
correctly does nothing at all.
18b. A right-press outside an open menu retargets it.¶
The menu is opened by right-clicking a row. Right-clicking a different row while it is open is the same gesture aimed somewhere else — "not that one, this one" — and charging two gestures for it (dismiss, then click again) is the kind of friction that makes a feature feel unfinished.
The menu cannot do the retarget itself: it has no view of what is under the
pointer in the layer beneath. But it is the only thing that knows whether the
press landed on it (rule 28 — nothing outside a component decides a click was
its own), so it declines the event and reports where it went as
RetargetMsg{Event}, carrying the press untouched. The host routes that to
whatever is underneath and reopens against the result — the same path a fresh
right-click takes, which is what keeps the two entry points from drifting.
Three properties fall out and are worth stating, because each is a way this could have gone subtly wrong:
- A left press outside still dismisses. Retargeting and dismissing are different gestures and must not collapse into one.
- A right press inside the menu does nothing. There is no second level of menu to ask about.
- A host that ignores
RetargetMsgis left exactly where it was — the menu does not close itself, because it does not know whether the host will find anything to reopen against. That is what made this safe to add after the component had shipped.
18. A single click commits a menu row.¶
Rule 28 says single click focuses and moves the cursor, double click
activates. A menu row is exempt, and the exemption is already the library's
rule rather than a new one: confirm and alert commit their buttons on
IsPress(), and pkg/tab switches tabs on a single press, under rule 28's
own "library-drawn chrome is clickable" clause. A menu row is a button, not
a data row. Worth stating explicitly, or it reads as a bug and someone
"fixes" it.
19. Marking lives in list and table, keyed by KeyedItem/KeyedRow.¶
Acting on one row at a time is the case the menu obviously serves; acting on several is the case that makes it worth building. Marking belongs in the data component the user is already looking at — not in the menu, and not in a separate picker modal — because the rows the user wants are the rows in front of them, and a modal that re-lists them is a second copy to keep in sync.
Marks are keys, not indices, and that is not a detail. Rule 24's whole
point is that a polled refresh reorders and partially replaces the row set;
index-based marks would drift onto unrelated rows between the mark and the
verb, which on Delete is the worst bug this library could ship. The
substrate already exists — KeyedItem / KeyedRow were added so the cursor
could survive the same swap — so marking is that primitive with a
map[string]bool in place of one index. Marking is therefore available only
on keyed data; on anonymous SetItems([]string) the mark keys are no-ops
and Marks() is empty.
- Opt-in per component via
Options.Markable, matchingFilterableandHScrollbar. Off, there is no marker column, no binding, and no width cost. spacetoggles the cursor row. It is unbound in bothlistandtable, and already means "toggle" intree,inspectorandtoggle— so it extends the existing vocabulary instead of inventing one.pkg/treeis excluded for exactly that reason:spaceis already spent there on expand/collapse, and marking a tree raises a question this design does not answer (does marking a branch mark its children?).Amarks every currently visible row — visible meaning post-filter, which makes "filter toregion:eu, mark all, restart" a three-keystroke flow. On a windowed table (SetWindow) it marks the resident window only; the count stays honest because it is a count of what is actually marked.- Marks survive filtering, since they are keys. That means you can mark a
row, filter it out of sight, and still act on it — which is why decision 2's
Set.Targetis not cosmetic. The menu title is the disclosure. - The marker column is two cells in
list(cursor▸then mark✓) and one intable, which shows its cursor with a background rather than a glyph and so needs a column only for the✓. - A windowed table cannot be marked. Found while building, not while
designing:
SetWindowcarries[]Rowwith no keys, so a mark there could only be held by index in a sparse paged set — the exact drift this decision refuses. Marking is inert underSetWindowrather than approximate. Giving remote sources marks needs a keyed window setter, which is its own change.
19b. The marking tests live in internal/componenttest, not in either component.¶
list and table implement marking separately — their models differ too much
to share code, and the repo's grain is parallel implementations per component.
That is precisely the shape the "Don't test shared behaviour in one
component's package" anti-pattern was written about: click-to-blur was written
for pkg/list, rolled out to five others by a script that omitted it, and
tested only where it was written, so five components shipped broken with a
green suite.
So the contract — space toggles, marks are keyed and survive both a swap and a
filter, mark-all respects the filter, anonymous data is inert, the gutter
click toggles without opening, Selection falls back to the cursor — is
asserted once against both components through a small adapter interface. When
tree eventually grows marking, it joins the table rather than getting its
own copy of the assertions.
20. Selection() collapses "the marked set, or the cursor".¶
func (m Model) Selection() []string // marks if any, else the cursor's key
func (m Model) SelectionLabel() string // "cache-redis" or "3 items"
Without this, every screen writes if marks := t.Marks(); len(marks) > 0 { … }
else { … } — a branch that is easy to write once and easy to forget, and
whose failure mode is a verb silently acting on one row when the user marked
six. One accessor makes the single-selection case the zero-mark case of the
multi-selection case, so screens have one path.
SelectionLabel exists so the count reaches the confirm string and the menu
title without every app re-inventing the pluralization.
21. Arity is declared, and defaults to one.¶
Marking creates a question every action now has to answer: does this verb
accept more than one target? "Restart" does — three pods, three restarts.
"View logs" does not, because it pushes a screen and there is only one screen
to push. Nothing about Run vs Do settles it: a Do that opens a browser
tab per target is perfectly sensible, and a Run that acquires a single
cluster-wide lock is not.
// Multi reports whether this action accepts a selection of more than one.
// The zero value is false: an action acts on exactly one target unless it
// says otherwise.
Multi bool
With more than one target marked, the menu renders every non-Multi action
disabled with a stock reason ("acts on one item at a time"). No screen writes
the guard.
The default is the safe direction, and that is the whole reason it is a
field rather than an inference. If the default were "accepts many," an author
who forgot would ship a logs action that, on a three-row selection, either
pushes three screens or silently picks one arbitrarily — a bug you find in
production. With the default at one, forgetting produces a disabled row with
an explanation, which is a bug you find in the first five seconds of using
your own screen. Same reasoning as confirm.Options.Initial defaulting to the
cancel side and app.Options.Mouse defaulting off: when omission has to mean
something, it should mean the harmless thing.
This replaces the hand-written Disabled: onlyOne(targets) guard that an
earlier draft of this document's example needed, which is the usual sign that
a field was missing.
Reserved keys¶
The second half of the request, and the part that pays off immediately even if none of the above ships.
The registry¶
A new leaf package, pkg/keys, depending on nothing in tuilib:
type Scope int
const (
Global Scope = iota // the app shell owns it — never bind, anywhere
Universal // rule 25's scroll + search vocabulary — no component may bind it
Component // a specific component owns it while focused
)
type Reservation struct {
Key string // "ctrl+u", "g", "/"
Scope Scope
Owner string // "pkg/app", "rule 25", "pkg/table"
Verb string // "quit", "half-page down", "sort direction"
}
func Reserved() []Reservation
func Lookup(k string) (Reservation, bool)
func Check(bindings ...key.Binding) []Conflict
func Free() []string // unclaimed single characters, for authors picking one
Check is what an app author calls in their own test. Reserved() is what
generates the table below, so the doc cannot drift from the code.
What is claimed today¶
Global — the shell owns these; nothing else may bind them.
| Key | Owner | Verb |
|---|---|---|
q, ctrl+c |
pkg/app |
quit (at stack depth 1) |
esc |
pkg/app |
pop the stack |
? |
pkg/app |
key overlay |
ctrl+z |
pkg/app |
suspend |
t |
pkg/app |
cycle theme (opt-in, conventional) |
o |
pkg/app |
output console (opt-in, conventional) |
a |
pkg/app |
actions menu (opt-in, conventional) |
Universal — rule 25's vocabulary. No component and no screen may rebind these.
| Keys | Verb |
|---|---|
↑ k / ↓ j |
line up / down |
← h / → l |
scroll left / right |
g / G |
top / bottom |
ctrl+u / ctrl+d |
half-page up / down |
pgup / pgdown |
page up / down |
0 home / $ end |
line start / end |
/ |
search or filter |
\ |
filter mode |
n / N |
next / previous match |
tab / shift+tab |
focus cycling (pkg/focus) |
Component-claimed — owned by a specific component while it has focus. An app-level binding that collides here is a latent bug that surfaces only when that component happens to be focused, so treat them as spoken for.
| Keys | Owner | Verb |
|---|---|---|
[ ] s |
pkg/table |
sort column −/+, direction |
shift+← shift+→ |
pkg/table |
previous / next column edge |
shift+← shift+→, 1–9 |
pkg/tab |
switch tab |
space |
pkg/tree, pkg/inspector, pkg/toggle |
toggle (expand/collapse in tree — never marking) |
x |
pkg/list, pkg/table, pkg/tree |
mark row (when Markable) |
space |
pkg/list, pkg/table |
mark row (when Markable) |
A |
pkg/list, pkg/table, pkg/tree |
mark all visible (when Markable) |
X |
pkg/list, pkg/table, pkg/tree |
mark anchor↔cursor, either direction (when Markable) |
D |
pkg/list, pkg/table, pkg/tree |
clear marks (when Markable) |
E C |
pkg/tree, pkg/inspector |
expand / collapse all |
{ } J K |
pkg/tree, pkg/inspector |
sibling / leaf navigation |
w |
pkg/textview |
toggle wrap |
c x w |
pkg/output |
clear, kill run, export |
y n |
pkg/confirm |
yes / no |
enter |
everywhere | activate the focused selection (rule 16) |
What is free¶
After a goes to the actions menu, the unclaimed lowercase letters are:
b d e f i m p r u v z
Most uppercase letters are free (B F H I L M O P Q R S T U V W Y Z),
as is most punctuation and most ctrl+ combinations outside
A/c/d/u/z.
The point of publishing this is not to encourage spending it. It is that an
author who needs a key can now find one in five seconds instead of grepping,
and that the answer to "may I bind s?" stops being "probably, unless there's
a table on screen."
Enforcement¶
Documentation did not prevent the last five components from shipping a bug
that was written down as a rule; a test in internal/componenttest did. Same
shape here:
- A test walks every component's
DefaultKeys()and asserts no binding claims aGlobalkey or rebinds aUniversalone to a different verb. keys.Checkis exported so an app's own test asserts the same about itsOptionsoverrides and itsAction.Keyvalues.action.Validatefoldskeys.Checkin, so a screen's action set is checkable in one call.
What lands where¶
| Package | Change |
|---|---|
pkg/action |
new. Action, Set, Func, Provider, Menu (self-sizing anchored overlay component), OptionsFrom, Validate. |
pkg/keys |
new. Registry, scopes, Check, Free. Leaf, no tuilib imports. |
pkg/geom |
AnchorIn(outer, x, y, w, h). |
pkg/list, pkg/table |
Options.Markable; space / A; marker column; Marks/SetMarks/ClearMarks/Selection/SelectionLabel. |
pkg/runner |
Go(label, fn); CaptureStarted.Detail; package doc widened. Still dependency-free. |
pkg/mouse |
Msg.IsRightPress(). |
pkg/app |
Options.ActionsKey; overlay slot + routing mode; right-press handling; invocation logging; outcome → statusbar; hint gating. |
| components | right press treated as focus + cursor move (list, table, tree, inspector, logview, textview, form, input, toggle). |
internal/componenttest |
reserved-key conformance; right-press-focuses conformance; marks-survive-a-keyed-swap conformance across list and table. |
examples/patterns/actions |
new. Marking, menu from key and right-click, a background action over a marked set, a Confirm action, a Disabled action, a Do action that pushes a screen. |
CLAUDE.md |
rules 30, 31, 32 (drafted below). |
Build order. action.Menu first, hosted by a screen in an example the way
pkg/confirm is — it is the piece with the most design risk and the least
coupling, and per decision 17 it does not depend on the shell wiring. Marking
second, since it is independent of the menu and useful on its own. The shell
wiring (ActionsKey, overlay routing, right-click) last, once the component
has been used enough to know it is right. pkg/keys can land at any point and
is worth doing early — it is the piece that pays off even if the rest stalls.
Proposed rule 30 — Actions¶
Give a screen's verbs to
pkg/action, not to the footer. A screen declares what can be done to the current selection by implementingActions() action.Set; the shell owns the key, the right-click, the menu, the goroutine and the reporting. PreferAction.Run— background work under a cancellable context, streamed into the output console under the action's label as one event, with the outcome on the statusbar — and reach forAction.Doonly for verbs that are not background work (pushing a screen, handing the terminal to$EDITOR). Never do the work inline inUpdate: a value receiver that blocks freezes the whole TUI, and nothing about the signature warns you.Declare the two things the menu cannot infer.
Multimeans the verb accepts a selection of more than one — omit it and the menu disables the action the moment a second row is marked, which is the right way round: forgetting costs you a disabled row you notice immediately, not a "view logs" that picks one of three targets at random.Exclusivemeans it must not overlap a run of itself on the same target, and the menu says "already running" instead of dropping the press. Neither is inferable fromRunvsDo— aDoopening one browser tab per target isMulti, aRunholding a lock isExclusive.Set
Confirmon destructive verbs rather than hand-rolling a modal, andDisabled(with a reason) rather than hiding an action the user is looking for. Scope actions by focus — aswitchovers.focus.Is(&…)— so the menu describes the pane the user is pointing at, and setSet.TargetfromSelectionLabel()so a destructive verb states its blast radius before it runs.
Proposed rule 31 — Reserved keys¶
Check
pkg/keysbefore binding a letter. The shell ownsq,esc,?,ctrl+zand, where the app opts in,t,oanda; rule 25's scroll and search vocabulary is reserved library-wide; the rest belongs to whichever component is focused.keys.Free()lists what is actually unclaimed, andkeys.Checkturns a collision into a test failure instead of a bug that appears only when a table happens to be on screen. If you find yourself shopping for a letter, that is usually the signal the verb belongs in the actions menu (rule 30) rather than on a key.
Proposed rule 32 — Marking¶
Multi-selection is keyed, or it is wrong. Set
Options.Markableon alistortableand feed it throughSetKeyedItems/SetKeyedRows— neverSetItems/SetRows. Marks are held byKeyfor the same reason the cursor is (rule 24): a polled refresh reorders and partially replaces the row set, and index-held marks drift onto unrelated rows between the mark and the verb. On anonymous items, marking is inert rather than approximate.Read the result with
Selection(), notMarks()— it returns the marked set when there is one and the cursor's key when there isn't, so a screen has one path instead of a branch it can forget.SelectionLabel()is its display counterpart, for confirm strings andSet.Target.Marks are keys, so they survive filtering: a user can mark a row, filter it out of view, and still act on it. That is the correct behaviour and a genuine surprise, which is why the menu title always names the target.
21b. The keyboard centers the menu. A top anchor was tried and reverted.¶
Recorded because the reasoning that produced it was wrong in an instructive way, not because the outcome is interesting.
The observation was that on a tall terminal a centered menu sits well below the rows it acts on, and might therefore go unnoticed. That was a misdiagnosis: it came from cropped terminal captures during verification, not from anyone failing to find the menu. The report that triggered it was about marking being invisible, and the reporter had plainly seen the menu — they described it running a verb and closing.
Anchoring at the top did put the box nearer the rows, and made things worse: it covered the host pane's filter row and its rule, so the frame read as broken. Centering leaves the pane's chrome intact, and the middle of the pane is where a modal is expected anyway.
The lesson worth keeping is about the evidence, not the placement — a fix was designed, approved and shipped for a problem that only existed in the measuring instrument.
21c. One subject per example.¶
examples/patterns/actions originally demonstrated the menu and marking. Both
worked; neither read. A reader met it as a menu demo, found a picker that runs
one verb and closes — correct behaviour — and never discovered that rows could
be marked first, because nothing on screen said so.
Marking is now out of that example, which demonstrates the menu, Confirm,
Do versus Run, and background reporting. Marking belongs in an example
whose subject it is, ideally a polling table where keyed marks visibly survive
a refresh — which would also cover table marking, currently demonstrated
nowhere.
22. The shell claims a modal's results only while it is the one showing it.¶
pkg/confirm reports through ConfirmedMsg / CancelledMsg, and the shell
now hosts a confirm of its own for Action.Confirm. Those messages are
neither keys nor mouse, so they do not travel through the overlay's input
routing — they arrive at the top of Update like any other message, and the
shell has to claim them there.
Claiming them unconditionally would break every screen that hosts its own
confirm modal (examples/patterns/modals, pkg/output's kill picker): the shell
would swallow the result and the screen would never dismiss. So both branches
are guarded on the shell actually having a dialog up. It is one if, and the
failure it prevents is silent — the modal simply stops responding.
This surfaced as a bug during implementation, not in review: the first version handled the results inside the overlay's input router, where a message that is neither a key nor a click never reaches.
Open questions¶
-
Does the actions key deserve a statusbar affordance the way the output console got a badge? A badge counts unread events; an actions menu has no equivalent state to report, so probably not — but "this screen has verbs" is arguably worth a glyph. Currently proposed as help-hint only.
-
Should
Doactions get a completion line? They can't be tracked (the returnedtea.Cmdis opaque), so today they get the invocation line and nothing else. An action that pushes a screen needs nothing more; one that fires off a fetch looks like it vanished. Possible answer:Doactions that want reporting useapp.Info/ErrorOfas they do today, and the invocation line is what ties them together. -
Right-click on a component that isn't the focused one currently focuses it, which changes keyboard focus as a side effect of asking a question. That matches left-click (rule 28) and it is what makes
Actions()report on the right pane, so it's proposed as-is — but it is the one place where "look at this" and "work here" are conflated. -
Does right-click on an unmarked row clear the marks? Every desktop file manager says yes: right-clicking outside a selection replaces it. But marks here survive filtering and refreshes deliberately, and silently discarding six of them because the pointer landed on a seventh row is the kind of loss that has no undo. Proposed as-is (right-click moves the cursor, marks untouched, menu title discloses the real target), but it is the decision most likely to feel wrong in use.
-
What kills a
Runaction from the menu rather than the console? AnExclusiveaction already in flight renders as "already running", and the only way to stop it isothenx. A "Cancel" row in the menu, replacing the disabled one, is the obvious affordance — deferred until the base flow is real, since it is additive. -
A
Markablecomponent does not advertise that it is markable. Nothing in the pane says rows can be marked, so the feature is invisible until someone presses space on a hunch. The pane's bottom-right slot is free andspace: mark/3 markedwould fill it — the single change most likely to make marking discoverable. Not done. -
Should the marker column render when nothing is marked? Always-on costs two cells of width on every row for a feature most rows never use; appearing on the first mark reflows the content under the user at the worst moment. Leaning toward always-on when
Markableis set, sinceMarkableis already opt-in per component.