Documentation
¶
Overview ¶
Package keys is the one registry of the whole approved screen × key table (docs/audit/2026-09-ux-arch-audit.md, "Proposed keymap"), plus the two things every screen needs to act on it: a write-binding matcher that tells legacy-shift from caps lock (§9 entry 13), and the footer helper that lays out hints under §9 entry 10's "the frame owns every row" rule.
This package imports internal/ui (for ui.Styles, in Footer) and bubbles/key, huh and bubbletea; ui must never import keys back — keys is one layer further from the terminal, built on top of the palette rather than under it.
The audit doc's migration plan moved every screen in internal/app to this package's registry, matcher and footer helper (AGENTS.md §4.8); a raw bubbles keymap or `key.NewBinding` call outside this package is a regression now, frozen by internal/copycheck rather than left to review. A binding's Name is the semantic id a keypress resolves to ("refresh", "abandon") — one letter has exactly one Name across every screen it is bound on (audit rule 1), enforced by TestLetterOneMeaning below, not by review alone (AGENTS.md §10 meta-rule 5).
Index ¶
- Variables
- func Footer(st ui.Styles, width int, status string, hints []Hint, help bool) string
- func Has(s Screen, b Binding) bool
- func HelpTitle(s Screen) string
- func HelpView(s Screen, kbd tea.KeyboardEnhancementsMsg) string
- func HuhKeyMap() *huh.KeyMap
- func ViewportKeyMap() viewport.KeyMap
- type Binding
- type Class
- type Entry
- type Group
- type Hint
- type Screen
Constants ¶
This section is empty.
Variables ¶
var ( Enter = Binding{Name: "primary", Class: Primary, Group: Act, Keys: []string{"enter"}, Show: "enter"} Esc = Binding{Name: "back", Class: Back, Group: Navigate, Keys: []string{"esc"}, Show: "esc"} Up = Binding{Name: "up", Class: Spatial, Group: Navigate, Keys: []string{"up", "k"}, Show: "↑"} Down = Binding{Name: "down", Class: Spatial, Group: Navigate, Keys: []string{"down", "j"}, Show: "↓"} Left = Binding{Name: "left", Class: Spatial, Group: Navigate, Keys: []string{"left"}, Show: "←"} Right = Binding{Name: "right", Class: Spatial, Group: Navigate, Keys: []string{"right"}, Show: "→"} PgUp = Binding{Name: "pgup", Class: Spatial, Group: Navigate, Keys: []string{"pgup"}, Show: "pgup"} PgDn = Binding{Name: "pgdn", Class: Spatial, Group: Navigate, Keys: []string{"pgdown"}, Show: "pgdn"} Home = Binding{Name: "home", Class: Spatial, Group: Navigate, Keys: []string{"home"}, Show: "home"} End = Binding{Name: "end", Class: Spatial, Group: Navigate, Keys: []string{"end"}, Show: "end"} Tab = Binding{Name: "tab", Class: Spatial, Group: Navigate, Keys: []string{"tab"}, Show: "tab"} Space = Binding{Name: "space", Class: Verb, Group: Act, Keys: []string{"space"}, Show: "space"} Filter = Binding{Name: "filter", Class: Verb, Group: Act, Keys: []string{"/"}, Show: "/"} Help = Binding{Name: "help", Class: Verb, Group: AppGroup, Keys: []string{"?"}, Show: "?"} Quit = Binding{Name: "quit", Class: App, Group: AppGroup, Keys: []string{"q"}, Show: "q"} CtrlC = Binding{Name: "ctrl-c", Class: App, Group: AppGroup, Keys: []string{"ctrl+c"}, Show: "ctrl+c"} Refresh = Binding{Name: "refresh", Class: Verb, Group: View, Keys: []string{"r", "f5", "ctrl+r"}, Show: "r"} Open = Binding{Name: "open", Class: Verb, Group: View, Keys: []string{"o"}, Show: "o"} Diff = Binding{Name: "diff", Class: Verb, Group: View, Keys: []string{"d"}, Show: "d"} Promote = Binding{Name: "promote", Class: Verb, Group: Act, Keys: []string{"p"}, Show: "p"} Tag = Binding{Name: "tag", Class: Verb, Group: Act, Keys: []string{"t"}, Show: "t"} Watch = Binding{Name: "watch", Class: Verb, Group: Act, Keys: []string{"w"}, Show: "w"} Log = Binding{Name: "log", Class: Verb, Group: View, Keys: []string{"l"}, Show: "l"} Config = Binding{Name: "config", Class: Verb, Group: View, Keys: []string{"c"}, Show: "c"} Edit = Binding{Name: "edit", Class: Verb, Group: Act, Keys: []string{"e"}, Show: "e"} Restart = write('r', "restart") Abandon = write('x', "abandon") Direct = write('d', "direct") CINone = write('c', "ci-none") // HalfPageDown and HalfPageUp are the vim-style ctrl+d/ctrl+u half-page scroll some // screens' viewport bodies also answer to alongside pgup/pgdn — not part of the approved // keymap's own Screen × key table (rule 7 names only ↑/↓, ←/→, pgup/pgdn, home/end as the // shared navigation set), so neither is ever added to a Screen's registry row: a bonus // muscle-memory alias a footer or the help overlay never advertises, built through this // package only so no screen's viewport wiring calls key.NewBinding directly any more // (internal/copycheck's TestNoKeyNewBindingOutsideKeys). HalfPageDown = Binding{Name: "halfpagedown", Class: Spatial, Group: Navigate, Keys: []string{"ctrl+d"}, Show: "ctrl+d"} HalfPageUp = Binding{Name: "halfpageup", Class: Spatial, Group: Navigate, Keys: []string{"ctrl+u"}, Show: "ctrl+u"} )
The shared bindings every screen's table (registry.go) is built from. Zero value means "unbound on this screen" — a Binding not listed in a Screen's On() result never fires there.
Functions ¶
func Footer ¶
Footer lays out one status-bar line: status on the left, hints on the right, joined by " · " in the order given (the audit doc, "Footer rules"). help, when true, appends the ? hint itself — the caller never builds one — so "? help" becomes "? more" once anything else is dropped is Footer's own bookkeeping rather than something every call site has to get right (spec wins over any mockup still showing "? help" once a screen is that narrow, this PR's binding decision). esc, if present in hints, is named by Binding and is never dropped alongside it. status is cut before any hint is touched, and removed entirely rather than shown as a near-unreadable fragment once its own available room drops under 8 cells. The result is never wider than width, because ui.StatusBar — the last step — guarantees it.
func Has ¶
Has reports whether b is bound on screen s — compared by Name, a Binding's one stable identity, since two Binding values built the same way are not otherwise comparable in a way that survives being copied into a row.
func HelpTitle ¶
HelpTitle is the overlay's own dialog title for screen s — "help · matrix" — the same string ui.Dialog draws around HelpView's body, kept as its own function so app.go's View never hand-formats it a second way.
func HelpView ¶
func HelpView(s Screen, kbd tea.KeyboardEnhancementsMsg) string
HelpView renders the full-key overlay's BODY for one screen: every binding On(s) lists, grouped under the mockup's own four headings, the "shift+ keys always ask before they write" line whenever the screen has at least one Write binding, and one line naming whether this run can tell a caps-lock letter from a real shift — recorded once at the root from tea.KeyboardEnhancementsMsg rather than threaded into every value-typed screen (the audit doc's own "needs your decision", resolved that way). The caller (app.go) draws this inside ui.Dialog, which supplies the box, the title (HelpTitle) and the dimmed background itself — HelpView returns plain content, never its own box, so the two are never nested one inside the other.
func HuhKeyMap ¶
HuhKeyMap is the one huh.KeyMap every standalone huh field in this app should use, built from huh.NewDefaultKeyMap() rather than a zero one (AGENTS.md §9 entry 6: a bare &huh.KeyMap{} ignores every key, which shipped broken twice before that rule existed).
Two changes from the default:
- g/G (goto top/bottom) are stripped everywhere they exist, since g/G retire under the keymap's own rule 7 (home/end replace them; h/l already retired as arrow aliases).
- MultiSelect's Toggle is space only — the default also binds "x" for toggle, which the keymap's rule 5 reserves for abandon (a Write binding on the screens that use it) and the migration checklist's own plan-screen row (x toggle repo → space toggle repo).
func ViewportKeyMap ¶
ViewportKeyMap is the one viewport.KeyMap every read-only scrolling body in this app should use — config, activity and the tag picker's commit reader already built this same literal by hand (three copies drifting independently); plan, deploy, flight, restart and watch's own viewports were left on viewport.New()'s bubbles-library default instead, which binds space/f/b to PageDown/PageUp, "d"/"u" (bare, no ctrl) to half-page, and left/right/h/l to horizontal scroll — none of it ever shown on a footer or help overlay, all of it live. That is why space still scrolled the deploy confirm screen after the audit retired the gesture, and why "d" (yaml diff on plan/deploy, unbound everywhere else) paged flight/watch/restart's own log/body out from under whatever the screen's own switch thought "d" meant.
Only paging and line movement are bound, through the same bindings the footer/help overlay already show (PgUp/PgDn/HalfPageUp/HalfPageDown/Up/Down) — Left/Right are left unbound (zero-value, so they do nothing). That "no screen needs horizontal scroll" assumption held for hard-truncated content but not for a long yaml line: the plan impact pane and deploy's own diff viewport used to hard-wrap (bubbles' default when SoftWrap is off is actually a horizontal crop, not a wrap), which cut a long image reference off before its digest ever scrolled into view — and with Left/Right unbound there, there was no way to see the rest either. Both screens now set SoftWrap on their own viewport instead of wiring up horizontal scroll here, so the full line — digest included — is always on screen, just taller. A future viewport that genuinely needs horizontal scroll over unwrapped content should bind Left/Right explicitly at that call site rather than here, since this keymap is shared by several screens that scroll only vertically.
Types ¶
type Binding ¶
type Binding struct {
Name string
Class Class
Group Group
// Keys are the bubbles/key match strings for a non-write binding. A write binding leaves
// this nil and is matched by Matches below instead, which is stateless (the audit doc,
// "Matches for a write binding") rather than table-driven, since a write's whole point is
// telling shift from a legacy uppercase byte, which no fixed string list can express.
Keys []string
// Show is the only display form: "r", "shift+r", "↑↓", "enter" — never derived from Keys,
// so TestNoBareCapital can assert against exactly what a screen would render.
Show string
// contains filtered or unexported fields
}
Binding is one semantic key across the whole app: a Name (one meaning, matched against every screen it appears on by TestLetterOneMeaning), the Class and Group it belongs to, the bubbles match strings it responds to, and Show — the ONLY source a footer, the help overlay or a doc should render, so a write binding is never displayed as a bare capital letter (rule 5's own "never as the bare capital letter": a capital in a footer reads as "press this letter" and invites the caps-lock press a legacy terminal cannot tell from shift).
func (Binding) Bubbles ¶
Bubbles adapts a Binding for the components (viewport, table) that still take a bubbles key.Binding directly. A write binding has no fixed Keys list — WithKeys(upper) gives it the one legacy-path string a bare capital arrives as, which is the same string every terminal without flag 8 sends for both shift and caps lock (rule 5's "Modifier" note); Matches above is still what every screen in this package should call, since it alone can reject caps lock on a protocol-capable terminal.
func (Binding) Matches ¶
func (b Binding) Matches(msg tea.KeyPressMsg) bool
Matches reports whether msg is this binding's key. For every non-write binding it defers to bubbles' key.Matches over Keys. For a write binding it runs the stateless four-rule test from the audit doc's "Matches for a write binding" section:
- the base letter must match, case-insensitively;
- ctrl, alt or meta held rejects it outright — a write is a bare letter plus shift, never a chord;
- if the terminal reported ModCapsLock, ModShift must also be set — that combination is only possible once flag 8 (ReportAllKeysAsEscapeCodes) was granted, and a caps-lock letter with no shift is rejected rather than accidentally firing a write;
- otherwise ModShift, an upper-case Text, or an upper-case Code all count — the last of these is the rule that a legacy terminal's bare capital (no enhancement granted at all) still counts as shift, since that is the only signal a legacy terminal ever sends.
type Class ¶
type Class uint8
Class is the semantic category a key belongs to, shared across every screen (audit rule 1): a key's class never changes screen to screen, only what it resolves to. TestOneClassPerKey asserts every key string in the registry maps to exactly one Class.
const ( // Primary is enter: the screen's one deliberate write or review action (rule 2). Primary Class = iota // Back is esc: always back, never a cancel of anything already running (rule 3). Back // Spatial is the arrow/page/home/end/tab family: movement, never a write. Spatial // Verb is a lowercase, unmodified action key shared in meaning across screens (rule 6): // r/F5/ctrl+r refresh, o open, d diff, w watch, l log, ? help, / filter, space toggle. Verb // Write is a key that starts or confirms a remote write, and for that reason always // carries the shift modifier (rule 5): shift+r, shift+x, shift+d, shift+c. Write // App is q and ctrl+c: whole-program control, not a screen concern. App )
type Entry ¶
Entry pairs a Binding with the one-line description On(screen) shows it with — the same description text the help overlay and docs/guide.md draw from, so the wording only exists once.
type Group ¶
type Group uint8
Group is which help-overlay column a binding is listed under (v2·03 mockup: NAVIGATE, ACT, VIEW, APP).
const ( Act View AppGroup )
The four help-overlay columns, in the order the v2·03 mockup lists them.
type Hint ¶
Hint is one key shown in a screen's footer: the binding it names, its long and short display text, and a priority Footer uses to decide what gives way first when the terminal is narrow. A lower Pri is more important — Footer shortens and drops from the HIGHEST Pri down, so the hints a screen lists first (usually its own primary actions) survive longest by construction whenever the caller also gives them the lower numbers; nothing here assumes an order beyond what Pri states.
type Screen ¶
type Screen string
Screen names one screen's own row in the registry. ScrTagsReader is the tag picker's commit pane, which unbinds and rebinds a few of the list's own keys (→/← swap meaning) rather than being a screen the app ever pushes on its own stack.
Deviation from the audit doc's literal spelling: the design's Binding-var block and its Screen-const comment both name Watch, Restart and Config, which cannot coexist as Go identifiers at package scope. Every Screen constant here carries a "Scr" prefix instead, so keys.Watch/keys.Restart/keys.Config stay the Binding vars the design's four-write-binding note describes, and keys.ScrWatch/keys.ScrRestart/keys.ScrConfig name the screens.
const ( ScrMatrix Screen = "matrix" ScrMenu Screen = "menu" ScrPlan Screen = "plan" ScrDeploy Screen = "deploy" ScrTags Screen = "tags" ScrTagsReader Screen = "tags-reader" ScrFlight Screen = "flight" ScrWatch Screen = "watch" ScrRestart Screen = "restart" ScrConfig Screen = "config" ScrActivity Screen = "activity" )
The registry's screens, in the audit's own "Screen × key" column order.