From 7e76fa3dc3a399a8af1e6553c97b0b18148049ac Mon Sep 17 00:00:00 2001 From: stanlyzoolo <51911715+stanlyzoolo@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:50:14 +0300 Subject: [PATCH 1/4] docs: add the help-zoom implementation plan z from focusBrief/focusHelp toggles the panel layout 20/46/34 <-> 20/30/50, zooming panel [3] at the brief card's expense. Session-only view flag; the relayout is extracted into a shared applyLayout so the WindowSizeMsg handler and the toggle cannot drift. Mouse border-dragging was considered and rejected as YAGNI. Plan revised after an auto plan-review pass (boundary of the extraction, prevWrapW ordering trap, binding scope matched to R/H/M). Co-Authored-By: Claude Fable 5 --- docs/plans/20260807-help-zoom.md | 302 +++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/plans/20260807-help-zoom.md diff --git a/docs/plans/20260807-help-zoom.md b/docs/plans/20260807-help-zoom.md new file mode 100644 index 0000000..dedd899 --- /dev/null +++ b/docs/plans/20260807-help-zoom.md @@ -0,0 +1,302 @@ +# help-zoom: `z` toggles a wide-readme panel layout + +## Overview + +- `z` from `focusBrief || focusHelp` (in `modeNormal`) toggles the three-panel + layout between the standard 20/46/34 split and a zoomed 20/30/50 split that + gives panel `[3]` the room, taking it from the brief card. The binding scope + matches `R`/`H`/`M` — keys that change what panel `[3]` is are panel-`[3]` + keys, documented in the panel's own chrome, per the footer/status-bar rule + (`README.md:145-152`, CLAUDE.md **Status bar**). +- Solves "sometimes the readme wants to be wider" with one keypress instead of + hard-coded proportions; chosen over mouse border-dragging in the brainstorm + session (drag rejected as YAGNI — far more code and edge cases for the same + need; a zoom toggle does not preclude adding drag later). +- Session-only state; nothing is persisted. + +**Acceptance criteria** (what Task 5 verifies): +1. On a wide terminal (≥ ~106 cols) `z` toggles the width triple between + 20/46/34 and 20/30/50 and reports `readme zoomed` / `layout restored`. +2. At the 80×24 baseline (and any width where the minimum clamps make both + states identical) `z` refuses with status `too narrow to zoom` and flips + nothing. +3. With `helpZoom == false` every width, footer and overlay renders + byte-identical to today (full existing suite green, untouched). + +## Context (from discovery) + +- `internal/model/render.go:970` — `calcPanelWidths()`: the 20/46/34 ratios and + the 15/30/30 minimum clamps. The clamp cascade stays untouched; only the + brief ratio becomes parameter-dependent. +- `internal/model/model.go:1416` — the `tea.WindowSizeMsg` handler: today's + only relayout site. `prevWrapW` capture (1417) and the `m.width`/`m.height` + assignment (1418-1419) come first; the width recompute (1420) and + `vpH := m.calcListHeight()` (1421) precede the `!m.ready` branch and are + needed by **both** arms; `setToolsContent()` + card repaint (1458-1459) sit + after the branch, also common to both arms. +- `internal/model/model.go:2283` — `helpWrapWidth()` reads the **stored** + `m.helpW`, not `m.width` — the ordering constraint behind finding #2 below. +- `internal/model/render.go:1466` — `renderHelp()`: panel `[3]`'s title and + footer cells, with `ctrl+d/u page` pinned right through `panelFooter`'s + drop-from-the-right shed. +- `internal/model/render.go:806-836` — the `[?]` overlay's column 2. Row math + today: 7 (`[2] brief` title + 6 rows) + 5 (blank + `[3]` title + 3 rows) + + 4 (blank + `self` title + 2 rows) = 16, exactly the per-column budget that + `TestRenderHotkeysSizeBudget` (render_test.go:4527) pins at a framed height + of 20. Merging `e`/`#` frees one row (→15); adding `z` returns to 16. Width + is safe: column 2's key width is set by `ctrl+d/u` (8) and widest desc is + `update to latest` (16); `e/#`, `note / tags`, `z`, `zoom panel` all fit + under both. No test or doc asserts the unmerged `edit note`/`edit tags` + strings (grepped clean). +- Pattern to follow: `toggleGroupByTag` — a view toggle that reports through + `setStatus`, refuses dishonestly-no-op activations with a named status, and + fires no fetch (`assertOnlyExpiryTick`, status_test.go:30). +- Pure-core idiom for the no-op check: `baseFor`/`planFor`/`shellCommand` — + a parameterized core plus a thin wrapper, never a whole-`Model` copy. +- Key `z` is unbound today (no `case "z"` anywhere in `internal/model`). +- Clamp arithmetic, verified: at 80 cols (`available=74`) both states clamp to + `(14,30,30)`; the states first diverge around width 82; at 106+ the full + 20/46/34 ↔ 20/30/50 contrast holds. + +## Development Approach + +- **testing approach**: Regular (code first, then tests in the same task) +- complete each task fully before moving to the next +- make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in + that task — tests are a required part of the checklist, success and error + scenarios both +- **CRITICAL: all tests must pass before starting next task** — run + `go test -race ./...` +- **CRITICAL: update this plan file when scope changes during implementation** +- maintain backward compatibility: acceptance criterion 3 above + +## Testing Strategy + +- **unit tests**: required for every task; the model package's existing + hand-built `Model{}` + `WindowSizeMsg` test idiom covers all of this without + a TTY +- **e2e tests**: none in this project (VHS demos are regenerated manually via + the demo-gifs skill, out of scope here) + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document issues/blockers with ⚠️ prefix +- keep plan in sync with actual work done + +## Solution Overview + +- `m.helpZoom bool` on `Model` — the layout variant. Not an input mode + (`inputMode` is for modal input ownership; this is a view flag like + `m.groupByTag`). +- **`panelWidthsFor(zoom bool)`** — the pure width core; `calcPanelWidths()` + becomes the one-line wrapper `return m.panelWidthsFor(m.helpZoom)`. Tools + stays 20% in both states, brief takes 46% normally and 30% zoomed, help the + remainder; the existing minimum-clamp cascade (15/30/30) runs unchanged + after the ratios. +- **`applyLayout()`** — the relayout extracted from the `WindowSizeMsg` + handler. Boundary: it owns **everything after** the `m.width`/`m.height` + assignment, **including** the `!m.ready` arm (split out as its own + `initViewports(vpH)`), so the handler collapses to the two assignments plus + one call and there is exactly one relayout definition. The `prevWrapW` + capture moves to the **top of `applyLayout`, above the `calcPanelWidths` + assignment** — `helpWrapWidth()` reads the stored `m.helpW`, so capturing + after the recompute makes the comparison always-false and `[3]` silently + keeps stale wrapping on every resize and zoom, with all existing tests + green. This is the refactor's one sharp edge and gets its own test. +- **`toggleZoom() tea.Cmd`** (beside `toggleGroupByTag`): early-return `nil` + when `!m.ready` (explicitly, not by coincidence of the clamps); no-op with + status `too narrow to zoom` when `panelWidthsFor(true) == + panelWidthsFor(false)` (the clamps ate the difference); otherwise flip, + `applyLayout()`, status `readme zoomed` / `layout restored`. +- **Spotlight reset is a stated consequence**: `helpW ≥ 30` keeps + `helpWrapWidth()` off its floor, so any width-changing zoom changes the wrap + width, so `applyLayout` runs `setHelpContent()`, which zeroes `helpEntries` + and `helpNavIdx` — an active `j/k` spotlight in `[3]` is lost on `z`, + consistent with what a resize already does. +- The README re-render costs the **same as a resize**, not nothing: the width + is part of `readmeRenderCache`'s key, so each effective `z` re-renders the + body through glamour synchronously inside `Update()` (bounded by + `readmeMaxBytes`). Accepted — it is the exact cost profile of a width + resize today. +- Zoom is orthogonal to the `[3]` update-log takeover (`showsUpdateLog()` owns + *content*, zoom owns *width*); asserted by test, not left to manual checks. + +## Technical Details + +- Ratio selection inside `panelWidthsFor`: `briefPct := 46; if zoom + { briefPct = 30 }` feeding the existing `(available*briefPct)/100` line; + tools stays `20`. +- `z` dispatches from the same `focusBrief || focusHelp` gate as `R`/`H`/`M` + in `Update()`'s `modeNormal` key switch; the returned `setStatus` cmd is + what the case returns (a view toggle fetches nothing). +- Status wording is deliberately short: `too narrow to zoom` (18 cells). The + refusal is the one status whose trigger condition *is* a narrow terminal, + and `renderStatusBar`'s statusMsg branch does not truncate — an over-long + message would wrap the bar, the exact failure `TestStatusBarNeverWraps` + exists to catch. The message joins that test's narrow sweep. +- Footer cell: `m.hint("z", "zoom")` appended as the last left-side cell in + `renderHelp()`'s footer (least actionable — drops first under width + pressure), suppressed while `showsUpdateLog()` (matches the title dropping + the source hints). Note the shed geometry: at 80 cols the footer's left + budget (13 cells after the right reserve) is spent by `readme · 1/1`, so + the cell only shows from ~110 cols — which roughly coincides with the width + where `z` stops being a no-op, so the hint disappears about where the key + does nothing. Recorded so the test widths below don't look arbitrary. +- `[?]` overlay: merge `[2] brief`'s `e`/`#` rows into `{"e/#", "note / + tags"}` (the merged-pair idiom of `o/c` and `j/k ↑/↓`) and add + `{"z", "zoom panel"}` to `[3] readme`. Net rows unchanged in every self + state (arithmetic in Context above). + +## What Goes Where + +- **Implementation Steps**: code + tests + docs, all inside this repo +- **Post-Completion**: manual look at the real terminal, demo-gif decision + +## Implementation Steps + +### Task 1: panelWidthsFor pure core with the zoom ratio + +**Files:** +- Modify: `internal/model/model.go` (the `helpZoom` field) +- Modify: `internal/model/render.go` (`panelWidthsFor` + wrapper) +- Modify: `internal/model/render_test.go` + +- [ ] add `helpZoom bool` to `Model` with a doc comment naming it a view flag + (the `groupByTag` idiom), session-only +- [ ] extract `panelWidthsFor(zoom bool)` from `calcPanelWidths` with the + brief ratio parameter-dependent (46 → 30), tools fixed at 20, help the + remainder; clamp cascade untouched; `calcPanelWidths()` becomes the + one-line wrapper over `m.helpZoom` +- [ ] write `TestPanelWidthsForZoom` (table-driven, one model): at width 160 + the zoomed triple gives `[3]` the majority and `[2]` ~30%; the unzoomed + triple is byte-identical to today's expectations +- [ ] write the narrow rows: at 80 cols both flag states produce the identical + clamped triple `(14,30,30)`; around 82 they first diverge +- [ ] run `go test -race ./internal/model` — must pass before task 2 + +### Task 2: Extract applyLayout (and initViewports) from the WindowSizeMsg handler + +**Files:** +- Modify: `internal/model/model.go` + +- [ ] extract `applyLayout()` owning everything after the `m.width`/`m.height` + assignment: `prevWrapW` capture **first**, then width recompute + + `calcListHeight`, then the `!m.ready` branch (creation arm split into + `initViewports(vpH)`), then the wrap-width-changed `setHelpContent` + guard, then `setToolsContent()` + card repaint; the `WindowSizeMsg` + handler collapses to the two assignments plus `applyLayout()` +- [ ] double-check the sharp edge: the `prevWrapW` capture sits **above** the + `calcPanelWidths` assignment (`helpWrapWidth()` reads stored `m.helpW`; + captured below, the guard is always-false and `[3]` silently stops + re-wrapping with all existing tests green) +- [ ] write `TestApplyLayoutRewrapsHelpOnWidthChange`: after a width-changing + relayout, `[3]`'s content is re-wrapped to the new `helpWrapWidth()` — + the assertion that catches the always-false-guard mutation +- [ ] write `TestApplyLayoutIdempotent`: two calls at one width leave + identical `toolsW/briefW/helpW`, viewport dimensions and line maps — + kills the mutation where the extraction drops the `setToolsContent` + tail, which no existing resize test observes once the handler stops + owning it +- [ ] verify existing resize tests (`TestWindowSizeRebuildsLineMaps`, + spotlight-survives-height-resize) stay green untouched +- [ ] run `go test -race ./internal/model` — must pass before task 3 + +### Task 3: toggleZoom and the z key + +**Files:** +- Modify: `internal/model/model.go` +- Create: `internal/model/zoom_test.go` + +- [ ] implement `toggleZoom() tea.Cmd`: `if !m.ready { return nil }` first; + no-op with `setStatus("too narrow to zoom")` when + `panelWidthsFor(true) == panelWidthsFor(false)`; otherwise flip + `m.helpZoom`, `applyLayout()`, `setStatus("readme zoomed")` / + `setStatus("layout restored")` +- [ ] bind `case "z"` in `Update()`'s `modeNormal` switch under the same + `focusBrief || focusHelp` gate as `R`/`H`/`M`, returning the status cmd +- [ ] write tests: wide model — `z` widens `helpW`, status `readme zoomed`; + second `z` restores widths and says `layout restored`; `z` in + `focusTools` is a no-op (binding scope) +- [ ] write tests: 80×24 model — `z` leaves widths and flag untouched, status + `too narrow to zoom` +- [ ] write test: the returned cmd is only the expiry tick + (`assertOnlyExpiryTick`) — no fetch rides a view toggle +- [ ] write test: `z` typed in `modeSearch` stays query text — search is + handled inline (model.go:1491), **outside** the mode-dispatch switch, + so its safety is the one not structurally guaranteed; the editor modes + are covered by the dispatch switch and need no per-key assertion +- [ ] write test: zoom with an active `[3]` spotlight resets it + (`helpNavIdx == -1` after `z`) — the documented resize-consistent + consequence +- [ ] write test: `z` while `showsUpdateLog()` still relayouts and the log + survives in `[3]` (zoom is width, the log is content) +- [ ] add `too narrow to zoom` to `TestStatusBarNeverWraps`' narrow sweep +- [ ] run `go test -race ./internal/model` — must pass before task 4 + +### Task 4: Footer cell and [?] overlay row + +**Files:** +- Modify: `internal/model/render.go` +- Modify: `internal/model/render_test.go` (or the hotkeys/footer test files) + +- [ ] append `m.hint("z", "zoom")` as the last left cell of `[3]`'s footer, + suppressed while `showsUpdateLog()` +- [ ] merge the `[?]` overlay's `[2] brief` rows `e`/`#` into + `{"e/#", "note / tags"}` and add `{"z", "zoom panel"}` to `[3] readme` +- [ ] write test: at ~120 cols the `[3]` footer carries `z zoom` in + readme/help/man modes and not while an update log owns the panel; at + 80 cols the cell is shed by the footer's right-reserve arithmetic + (both assertions, with the shed geometry note from Technical Details + as the why) +- [ ] verify `TestRenderHotkeysSizeBudget` passes in all five self states + (row budget unchanged: 16 → 15 on the merge → 16 with `z`), adjust any + overlay-content assertions +- [ ] run `go test -race ./internal/model` — must pass before task 5 + +### Task 5: Verify acceptance criteria + +- [ ] verify acceptance criteria 1-3 from the Overview, each against its test +- [ ] run the full CI matrix locally via the preflight skill: build, vet, + `go test -race ./...`, golangci-lint +- [ ] run the cross-compile checks (`GOOS=windows go build` + vet, + `GOOS=darwin go build`) — CI runs them, preflight covers this + +### Task 6: [Final] Update documentation + +- [ ] update `CLAUDE.md`: the TUI state-machine section — the `z` toggle + beside the `[R]/[H]/[M]` trio it shares a gate with, the second ratio + triple in the panel-width note, the `[3]` footer cell, the `[?]` + overlay row merge (run the docs-sync skill to catch drift) +- [ ] update `README.md:145-152` (the panel-keys enumeration): add `z` to + panel `[3]`'s keys — wording reflects the `focusBrief || focusHelp` + scope +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion + +**Manual verification:** +- eyeball the toggle in a real terminal, wide and at 80×24 (the honest no-op) +- check the zoomed readme against a long README (wrap, entries navigation) + +**Demo gifs:** +- the hero gif shows panel `[3]`; decide with the demo-gifs skill whether the + new key belongs in the demo (it does not change resting visuals, so likely + no re-record) + +## Review log + +- 2026-08-07 plan-review (auto): NEEDS REVISION → revised. Adopted: the + `applyLayout` boundary rewrite incl. `initViewports` and the `prevWrapW` + ordering trap (#1, #2); `z` scoped to `focusBrief || focusHelp` per the + footer/status-bar rule, user-confirmed (#3); `panelWidthsFor` pure core + + `!m.ready` guard (#4); footer test widths 120/80 with the shed-geometry + note (#5); spotlight-reset consequence + assertion (#6); status shortened + to `too narrow to zoom` + wrap-sweep coverage (#7); idempotency test + replaces the conditional checkbox (#8); `modeSearch` singled out as the + non-structural assertion (#9); overlay row/width arithmetic recorded (#10); + "same cost as a resize" wording (#11); acceptance criteria added (#12); + README update made unconditional with the line range (#13); zoom-under- + update-log promoted from manual check to test. From 264412d86dd9aacc04b5525f5c3426bd5f4dfec0 Mon Sep 17 00:00:00 2001 From: stanlyzoolo <51911715+stanlyzoolo@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:09:12 +0300 Subject: [PATCH 2/4] feat: z widens the readme panel at the brief card's expense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sometimes the readme wants to be wider. z from [2] or [3] toggles the layout between 20/46/34 and 20/30/50 — the same gate R/H/M fire from, since it is another key that changes what panel [3] is. It moves no focus and fetches nothing; the state is session-only. Below ~82 columns the 15/30/30 minimum clamps make both variants identical, so the key reports "too narrow to zoom" instead of flipping a flag nothing follows. panelWidthsFor(zoom bool) is the pure core that makes that comparison possible without copying a Model. The relayout moves out of the WindowSizeMsg handler into applyLayout(), so the resize path and the toggle share one definition. The prevWrapW capture has to stay above the width recompute: helpWrapWidth() reads the stored m.helpW, so a capture below it kills the re-wrap guard and [3] keeps stale wrapping with the suite green. Tested. Footer: [3] gains a "z zoom" cell, last, so it sheds first and is dropped entirely while an update log owns the panel. The [?] overlay merges the e/# editor rows to buy the row for "z zoom panel" — the per-column budget was already at 16. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 15 +- CLAUDE.md | 8 +- README.md | 5 +- docs/design/readme-pipeline.md | 2 +- .../{ => completed}/20260807-help-zoom.md | 92 +++-- internal/model/model.go | 161 ++++++-- internal/model/render.go | 39 +- internal/model/render_test.go | 65 ++++ internal/model/zoom_test.go | 358 ++++++++++++++++++ 9 files changed, 657 insertions(+), 88 deletions(-) rename docs/plans/{ => completed}/20260807-help-zoom.md (79%) create mode 100644 internal/model/zoom_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 00c597a..6a831d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,7 +185,9 @@ Three panels with cycling focus: `[1] tools` (the list), `[2] brief` (the card), `[3] readme` (the README/`--help`/`man`/update-log view, switched by `R`/`H`/`M` — capitals as a set, so none of them collides with `r` refresh or the lowercase tracker verbs, and all three fire from `[2]` as well as `[3]`; `m.helpMode` is global, not per -tool, and defaults to the README). Focus moves with `→`/`←`, the digits +tool, and defaults to the README). `z` shares that gate as the fourth key that changes +what `[3]` is, but it changes the panel's *width* rather than its source, so it moves +no focus and fetches nothing (see the layout invariant below). Focus moves with `→`/`←`, the digits `1`/`2`/`3`, or a mouse click; everything goes through `setFocus(f)`, which repaints the tools list — the only viewport whose content depends on focus. @@ -278,6 +280,17 @@ Key invariants: lines, so it can never split an escape sequence or shift a card link's row. `calcListHeight()` is the single definition both the `WindowSizeMsg` handler and the renderers use, so a drift there cannot push the status bar off screen. +- **One width core, one relayout.** `panelWidthsFor(zoom bool)` produces the panel + widths — 20/46/34 normally, 20/30/50 under the `z` toggle — and `calcPanelWidths()` + is the wrapper reading the session-only `m.helpZoom` view flag. Parameterizing the + core (rather than copying a `Model` with the flag flipped) is what lets `toggleZoom` + compare the two states: below ~82 columns the 15/30/30 minimum clamps make them + identical, and the toggle then reports `too narrow to zoom` instead of flipping a + flag nothing follows. `applyLayout()` is the single relayout definition — the + `WindowSizeMsg` handler is two assignments plus that call, so the resize path and the + toggle cannot drift. Its one sharp edge: `prevWrapW` is captured from the *stored* + `m.helpW`, so the capture must sit above the width recompute or the re-wrap guard is + always false and `[3]` keeps stale wrapping, silently and with a green suite. - **A click's X picks the panel, `panelRow` decides whether it is on one at all.** The outer margin, the borders and the status bars share the panels' columns; with a scrolled viewport an unbounded row would map that chrome onto a list row or a card diff --git a/CLAUDE.md b/CLAUDE.md index 0e92be2..ea59958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu **Scrolling policy (unified, always-on)**: the default bubbles viewport keymap is **zeroed** on all three viewports right after `viewport.New` (`m..KeyMap = viewport.KeyMap{}`) and there is **no** keyboard fall-through into `viewport.Update` — every scroll key is bound explicitly in `Update()`'s switch, so an uncaught key is a deliberate no-op. This kills the old hidden default bindings (`d`/`u`/`f`/`b`/`space`/`h`/`l` scrolling uncaught keys inconsistently, and `h`/`l` horizontally shifting a viewport *after* a focus change). Wheel scrolling survives the zeroing because it rides `MouseWheelEnabled`, a field separate from `KeyMap`. The keymap must stay empty — re-installing the default reintroduces the leak. Explicit steps, identical across `[2]`/`[3]`: `j`/`k`/`↑`/`↓` scroll **3 lines** (in `[3]` with a non-empty `helpEntries` the letters `j`/`k` drive the spotlight cursor instead — the arrows always scroll); `ctrl+d`/`ctrl+u` half page; `ctrl+f`/`ctrl+b`/`PgDn`/`PgUp` and **`space`** (page-down synonym, kept for pager muscle memory) full page; `g`/`G` top/bottom. In `focusTools` these map to selection moves: `PgUp/PgDn`/`ctrl+f`/`ctrl+b` a full viewport-height page, `ctrl+d`/`ctrl+u` half a page (both measured in **screen lines** and resolved back to a tool by `toolNearLine`, then applied via `selectMeta`), `g`/`G` first/last tool (guarded on a non-empty list); `space` is **not** a page-down here — it toggles the tag-grouped list view (see **Tag grouping** below), which is why `ctrl+f`/`PgDn` carry the paging. The single letters `d`/`u`/`f`/`b` are deliberately **unbound** (`u` is update/untrack; `d`/`f`/`b` follow for consistency). -**Hotkeys overlay (`[?]`)**: `?` in `modeNormal` (any focus) opens `modeHotkeys` — a static, centered **two-column** grid listing every normal-mode binding one per line, each group named after the panel or area it belongs to (`global`, `[1] tools`, `[2] brief`, `[3] readme`, and the state-dependent `self`). Group headers are `EmphasisBold`, keys `Accent`, descriptions `Dim` like every other hint's label; the air between them is **horizontal** (`hotkeyKeyMinW` floors the key column so short keys do not put their descriptions right against them, `hotkeyKeyGap` and `hotkeyColGap` separate key from description and column from column) because the row budget is fixed by the 80×24 background while the width budget has ~15 columns of slack; the title carries the running build through **`displayVersion`**, which passes a release straight through and reduces Go's 44-character pseudo-version to `+` (`v0.1.0+0b86a47`, plus `-dirty` when buildinfo says so) — the raw value stays in `m.appVersion`, because the self-update gate reads it and has to keep seeing a working copy; inside a column every key cell is padded to the column's widest key so the descriptions line up and the keys never drift sideways. The **close hint lives in the title row** (`keys keepkit ` left, `esc close` right) rather than on a footer row of its own — a whole row saved, which is what the budget is spent on; `esc`, `q` and a second `?` all close it, and every other key is a no-op (`updateHotkeys` in mode.go). Hard size budget: **≤ 20 rows × ≤ 76 cols framed**, so it fits the 80×24 composited background that `PlaceOverlay` clips off the bottom. That leaves 16 rows per column and **both columns are at it** — a new binding needs a row freed, not appended, and `TestRenderHotkeysSizeBudget` is what says so (it checks all five self states, since the `self` group's presence and wording vary with them). The rows merged to buy that room (`o/c repo / releases`, `j/k ↑/↓ navigate / scroll`) are why some descriptions read as pairs. The `self` group is the one **state-dependent** part: absent at `selfNone` (both keys are unbound there, and on any dev build they always are — a fixed listing would advertise two dead keys), `U — self-update` / `X — dismiss` at `selfOffered`/`selfDismissed`, and `U — restart` / `X — later` at `selfUpdated`/`selfUpdatedLater`, because that is what the keys actually do there. It reuses the `[a]` overlay styling (`ui.PlaceOverlay` dimmed background, `Styles.OverlayBorder`). The global status bar carries a `? keys` hint. +**Hotkeys overlay (`[?]`)**: `?` in `modeNormal` (any focus) opens `modeHotkeys` — a static, centered **two-column** grid listing every normal-mode binding one per line, each group named after the panel or area it belongs to (`global`, `[1] tools`, `[2] brief`, `[3] readme`, and the state-dependent `self`). Group headers are `EmphasisBold`, keys `Accent`, descriptions `Dim` like every other hint's label; the air between them is **horizontal** (`hotkeyKeyMinW` floors the key column so short keys do not put their descriptions right against them, `hotkeyKeyGap` and `hotkeyColGap` separate key from description and column from column) because the row budget is fixed by the 80×24 background while the width budget has ~15 columns of slack; the title carries the running build through **`displayVersion`**, which passes a release straight through and reduces Go's 44-character pseudo-version to `+` (`v0.1.0+0b86a47`, plus `-dirty` when buildinfo says so) — the raw value stays in `m.appVersion`, because the self-update gate reads it and has to keep seeing a working copy; inside a column every key cell is padded to the column's widest key so the descriptions line up and the keys never drift sideways. The **close hint lives in the title row** (`keys keepkit ` left, `esc close` right) rather than on a footer row of its own — a whole row saved, which is what the budget is spent on; `esc`, `q` and a second `?` all close it, and every other key is a no-op (`updateHotkeys` in mode.go). Hard size budget: **≤ 20 rows × ≤ 76 cols framed**, so it fits the 80×24 composited background that `PlaceOverlay` clips off the bottom. That leaves 16 rows per column and **both columns are at it** — a new binding needs a row freed, not appended, and `TestRenderHotkeysSizeBudget` is what says so (it checks all five self states, since the `self` group's presence and wording vary with them). The rows merged to buy that room (`o/c repo / releases`, `j/k ↑/↓ navigate / scroll`, and `e/# note / tags` — merged when `z zoom panel` joined the `[3]` group, since column 2 was already at 16) are why some descriptions read as pairs. The `self` group is the one **state-dependent** part: absent at `selfNone` (both keys are unbound there, and on any dev build they always are — a fixed listing would advertise two dead keys), `U — self-update` / `X — dismiss` at `selfOffered`/`selfDismissed`, and `U — restart` / `X — later` at `selfUpdated`/`selfUpdatedLater`, because that is what the keys actually do there. It reuses the `[a]` overlay styling (`ui.PlaceOverlay` dimmed background, `Styles.OverlayBorder`). The global status bar carries a `? keys` hint. **Tool-list rows** (`renderLeftContent`) are three columns — marker, name, version — and the version is pinned to the right edge, which is what makes the list scannable down two edges at once: names on the left, what is installed on the right. The name column absorbs the slack between them and is **truncated, never wrapped**: a wrapped name would emit a second screen line for one tool and every entry in the line maps below it would be off by one (the old `wrapText` call could do exactly that). The width-1 marker column follows "one glyph, one meaning": the selected row always carries `⏺` (U+23FA) — accent while the list is focused, dim otherwise — so the selection never disappears when focus moves to brief/help. Every non-selected row gets a plain space; rows are indented one column past their section header (`toolRowIndent`) so a group reads as a group rather than as a label that happens to sit above some rows, and in the grouped view every section but the first opens with a **blank row** — a real screen line, non-selectable in the maps like the header itself. There is **no** status edge (tool status lives in the brief card only, editable via `s`). The **version column** shows what is installed: dim normally, `Signal` plus a trailing ` ↑` (U+2191) when a newer release exists, and `Ok` plus a ` •` on keepkit's own row — the one row that names the binary the user is looking at. That last marker is decided by the **name alone**, deliberately unlike `isSelfUpdate`, which additionally asks whether the self-update *feature* is live: this is identification, not an offer to act. The **selection is the marker and the name's weight, and nothing else**: the row carried a `Theme.Surface` fill for a while, and it turned every cursor move into a block sliding down the panel — far more motion than "this one is selected" is worth in a list that is scanned. `highlightNameMatch` still takes both its styles as parameters, so the unmatched halves of a name are never emitted bare beside a styled hit. `⏺`, `↑` and `•` are single-cell in go-runewidth's default condition (the one lipgloss measures with) but East-Asian **Ambiguous** — they measure 2 cells under `RUNEWIDTH_EASTASIAN=1`. This is accepted, not a regression: the removed `▎` edge was in the same class. `TestMarkerGlyphWidth` pins both conditions; `TestRenderLeftContentRowWidth` pins that every row is exactly the list width, which is what the fill depends on. @@ -105,12 +105,14 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Card meta block**: everything the user has told keepkit about the tool, plus what the repo says it is written in. It is **two blocks shaped by what they are**. The language stack is a *distribution*, so it gets the card's one picture: `languages · ● go 99% · ● shell 1%` (the full word heading its own list, separated from the first language by the same middot that separates two languages — it is the head of that list, not a caption over it), then **`renderLangBand`** under it, a row of exactly `inner` cells holding those same shares in **GitHub's own per-language colors**. Only the `●` carries color and the name+share read at one brightness — they are one fact, and five colored names would be a rainbow. The band's shares are normalized over the languages *actually listed* (`languagePercents` keeps the top five), because a band summed over the repo's total would leave a gap standing for languages the card never named; cells are handed out one per language first and the remainder by largest fractional part, so nothing listed is missing from the band and the row is exactly `inner` cells rather than `inner`±rounding. Its glyph is `▬` (U+25AC), **width-stable** like the gauge's `▮` — `█` is East-Asian Ambiguous and would double the band's footprint (`TestLanguageBandGlyphWidth`); the `●` beside a name deliberately is *not* in that class, since it rides in wrapped text where an over-wide measurement can only wrap a row early. The colors live in **`ui.LanguageColor`** (`internal/ui/lang.go`) and are the one thing in the app a theme switch must **not** repaint: `Theme` is keepkit's vocabulary of meanings, these are linguist's brand marks, and the whole value of a cyan dot beside `go` is that it is the cyan the reader has seen on every repo page. An unknown language falls back to `Dim` — unrecognized rather than wrong. Accepted caveat: linguist picks against a white page, so `lua`/`powershell`/`json` are near-black dots on a dark terminal. The band closes with **one blank row**: it runs the full width of the panel, so without one the line under it reads as a caption hanging off the bar rather than as the next thing (written bare — styling an empty string only emits an empty escape pair, the rule the changelog's blank rows follow). Below it, `status`, `tags` and `note` share **one wrapped line** — three short values that each took a whole row spent three rows on a sentence's worth of text, and the band above already gives the block its structure. Wrapping there is **by whole cells**: a cell carries ANSI, so it must never be cut mid-escape, and half a `note …` reads as noise anyway (the language block, which is not one line, is emitted above the loop rather than fed to it). Labels are `Dim`, values `Text`, and an **empty value does not get a line of its own**: it gets the key that fills it (`tags — # add`, `note — e write`), which is the only thing an empty field is good for. In edit mode the input replaces the value **in place**, so the card never jumps while it is being typed into. A value too wide for the panel is cut **before** it is styled and marked with `…` — cutting afterwards would land inside an escape sequence, which the viewport re-emits to the terminal verbatim. The card is not where a long note is read in full; the editor shows all of it. `TestMetaLineShape` pins the order and the band's exact width; `TestMetaLineFieldsWrapByWholeCells` pins that a narrow panel breaks the field line between two cells and never inside one. - **Card metrics strip**: `metricsStrip` is what the card's `[info]` section became — installed / latest / maintenance / stars laid out as **captioned columns on the `Theme.Surface` background** instead of six `label: value` lines whose labels ran down the left edge and pushed every value into a column of its own. Captions are uppercase (`INSTALLED`, `LATEST`, `MAINTENANCE`, `STARS`) because a terminal has no smaller type size to demote a label with, and the values are what the eye should land on. **`installed:` still has four states** and the two version-less ones stay distinct: a resolved version in `Text`, `✓ present` in `Ok` (a tool that is installed but won't name its version — a ratatui app that ignores `--version` — is a working install and reads affirmative), `✕ missing` in `Danger` (the one thing on the card that is actually wrong), and `detecting…` in `Dim` while the local probe is in flight. Both version-less values are **one word**, because the caption above them already says INSTALLED and the sentences they used to be were the only values in the strip too wide for a baseline-width column. `latest:` renders in `SignalBold` with a trailing ` ↑` when `hasUpdate`, otherwise `Text`, and the release date is a **second line under it** rather than a suffix on it. **The values sit at `Text`, one step below the tool's name and one above their own captions**: a terminal has a single font size — the grid belongs to the terminal, not to the app — so the three sizes the design draws in the card's head are three steps of weight and brightness here, and there is room for exactly one peak. Spending the brightest role on four measurements left the name nothing to be the peak of. The two exceptions carry meaning rather than rank: a pending release is one of the screen's three "act on this" points, and a broken install is its one alarm. The header block is separated from the strip by **one** plain blank row — the strip's own padding row is filled with the plate colour and already reads as air, so a second plain row on top of it reads as a hole. Both versions go through **`version.DisplayVersion`**, which puts a `v` in front of a bare version number: a tool's `--version` prints `1.10.2` where its release is tagged `v1.10.2`, and the two used to sit one letter apart for the same binary. It edits nothing else — `canonSemver` decides only *whether* the string is a version number (`nightly`, `cli-2.0` pass through untouched), and its own output is deliberately not what is displayed, since it drops zero-padding, a 4th segment and build metadata. The `\uf412` glyph the two version lines used to carry is gone with the labels that needed disambiguating: a caption says what the number is. A metric with nothing to report is **left out entirely**, so a tool with no GitHub ref shows a one-cell strip rather than three empty captions, and an empty strip is no strip at all. The grid **re-flows rather than truncates**, and it is sized by the widest *value* as well as the widest caption (`need`): sizing on captions alone cut `✕ not installed` to `✕ not insta` at the 80×24 baseline — the default terminal, and the exact state a tracker is opened in. The count is solved against the row the strip actually draws — a blank cell at each end plus a rule between every pair — by counting up while `2 + cols*need + (cols-1)*3` still fits, **not** by dividing `inner` by `need`: that division ignores the overhead, so a 40-cell panel was told it had three columns and then handed each of them 10 cells, cutting `MAINTENANCE` to `MAINTENANC` — the caption the floor exists to protect. At the 80-column baseline the card panel is 27 cells and even two columns need 29, so the grid stands on one; a value longer than any caption costs a column rather than its own legibility. Below `metricStripMinWidth` (`metricMinCol` plus the row's two blank edge cells — measuring against `metricMinCol` alone was two cells short, and a 12-cell panel drew a single 10-cell column) the strip stands down completely, which only a hand-built model reaches since the panel has a 30-cell minimum. **Every row is exactly `inner` cells** — a short row would break the fill into a ragged edge — and every segment carries the background itself for the reason the selected list row does. **Each cell is centered in its column** (odd slack to the right, so a caption and its value can differ by at most one cell in where they start): a caption and the value under it are one measurement, and flush-left hangs them off a rule that is nowhere near either. `TestMetricsStripLayout` pins the width, the caption-over-value reading (via `metricValue`, which identifies a column by the `│` rules around it rather than by the caption's start offset — centered caption and value deliberately do not begin at the same cell), the centering and the re-flow; `TestMetricsStripOmitsUnknowns` pins the omission. - **Panel titles**: all three panels inset a title into their top border (`┌─ ▸ [1] tools 27 3↑ ─…─┐`) via the shared `insetPanelTitle` — an ANSI-safe splice over the already-rendered frame (`ui`'s `truncateVisible` is unexported; the helper repaints the border runs from their `stripANSI` text and drops the title in **already styled**). That is what lets one title carry several colors, which the `[1]` counts are the point of: the tracked count in `Dim`, the update count in `Signal` — the tracker's whole reason to exist, so it is the one thing in a panel title that gets the signal color, and it is absent when nothing is behind. A `panelTitle` carries **both** a plain and a styled form: the plain one is what the border arithmetic measures, because escape sequences are not cells, and a title that does not fit is dropped whole (a chopped title reads worse than none). **Focus is marked twice** — by the accent color and by a `▸` prefix — so it survives a monochrome terminal and a reader who cannot separate the two panel colors; `TestPanelTitleFollowsFocus` pins both signals. The titles are lowercase (`[1] tools`, `[2] brief`, `[3] readme` / `[3] help` / `[3] man`, overridden by `[3] update` while a live log shows and by `[3] update finished` / `[3] update failed` once it ended) and double as the documentation for the digit focus hotkeys, so the status bar carries no digit hints. `[3]` additionally names **the two sources it is not showing** (`· h help · m man`) in the border color: those keys switch what the panel *is*, which is a property of the panel rather than an action on its content — and they are dropped while the update log owns it, since none of the three modes is what is on screen. All title characters are single-width and non-East-Asian-Ambiguous except `▸` (U+25B8, Ambiguous like the list markers, and measured with the plain form either way), keeping the border width math stable. -- **Panel footers**: each panel reserves the last `panelFooterRows` (2) of its content height for a **blank** spacer plus a footer line. `[1]` carried a border-colored rule there for a while and it only made the footer read as a fourth section of the list rather than as the frame's own caption. Cells are joined by a **dim** ` · ` — the same painted middot the card's language list uses, since an unpainted one renders at the terminal's default brightness, louder than the hint labels it is separating (`TestPanelFooterSeparatorIsDim`). `[1]` carries `/ filter · enter run · space group` — `enter` is a `[1]` action, not a global one (see **Status bar**), and the three are ordered most-important-first because cells drop from the right and on a narrow list "run" outranks "group"; a right cell that is absent now reserves **nothing**, gap included, which is what had been dropping `[1]`'s last cell one step earlier than the width required. `[2]` carries its own actions led by the contextual `enter update to ` when a release is pending (and deliberately **not** `e note` / `# tags`: both are already offered in the meta line beside the values they edit, and a footer repeating them spends the row on saying it twice), `[3]` the source name, the `page/pages` position, the entry-cursor hints while there is an index to walk, and `ctrl+d/u page` pinned right. Cells are dropped from the right until they fit, exactly like the status bar and for the same reason: a footer that wrapped would push the panel one row past its height, and lipgloss answers that by scrolling the top border off the alt screen. **`calcListHeight()` is the single definition** of the viewport height inside a panel (`calcVpHeight() - footerRows()`), used both by the `WindowSizeMsg` handler that sizes the viewports and by the renderers that stack viewport + footer back to the full height — two copies of that arithmetic would drift and cost a row. On a terminal too short to spare them (`calcVpHeight() < 6`) `footerRows()` is 0 and the panels are content only: two of six rows is a third of the panel, and a footer is a reminder while the content is the point. +- **Panel footers**: each panel reserves the last `panelFooterRows` (2) of its content height for a **blank** spacer plus a footer line. `[1]` carried a border-colored rule there for a while and it only made the footer read as a fourth section of the list rather than as the frame's own caption. Cells are joined by a **dim** ` · ` — the same painted middot the card's language list uses, since an unpainted one renders at the terminal's default brightness, louder than the hint labels it is separating (`TestPanelFooterSeparatorIsDim`). `[1]` carries `/ filter · enter run · space group` — `enter` is a `[1]` action, not a global one (see **Status bar**), and the three are ordered most-important-first because cells drop from the right and on a narrow list "run" outranks "group"; a right cell that is absent now reserves **nothing**, gap included, which is what had been dropping `[1]`'s last cell one step earlier than the width required. `[2]` carries its own actions led by the contextual `enter update to ` when a release is pending (and deliberately **not** `e note` / `# tags`: both are already offered in the meta line beside the values they edit, and a footer repeating them spends the row on saying it twice), `[3]` the source name, the `page/pages` position, the entry-cursor hints while there is an index to walk, the zoom toggle `z zoom` **last** (least actionable of the four — where you are in the text and how to walk it both outrank a width preference — and dropped entirely while an update log owns the panel, exactly as the title drops the source hints), and `ctrl+d/u page` pinned right. Measured shed geometry for that last cell: it survives from ~118 columns in readme mode and ~150 in `--help`/`man`, where the entry index adds a `j/k navigate` cell — so between ~82 (where `z` starts doing something) and that width the key works unadvertised here, which is what the `[?]` overlay, the one surface that never sheds, is for. Cells are dropped from the right until they fit, exactly like the status bar and for the same reason: a footer that wrapped would push the panel one row past its height, and lipgloss answers that by scrolling the top border off the alt screen. **`calcListHeight()` is the single definition** of the viewport height inside a panel (`calcVpHeight() - footerRows()`), used both by the `WindowSizeMsg` handler that sizes the viewports and by the renderers that stack viewport + footer back to the full height — two copies of that arithmetic would drift and cost a row. On a terminal too short to spare them (`calcVpHeight() < 6`) `footerRows()` is 0 and the panels are content only: two of six rows is a third of the panel, and a footer is a reminder while the content is the point. +- **Panel widths and the zoom toggle (`z`)**: the three panel widths come from **`panelWidthsFor(zoom bool)`** in render.go — 20% tools / 46% brief / remainder to `[3]` normally, 20/30/remainder zoomed, then the 15/30/30 minimum-clamp cascade unchanged. `calcPanelWidths()` is the one-line wrapper reading `m.helpZoom`, the session-only view flag `z` owns (a view flag like `groupByTag`, not an `inputMode` — nothing here owns input — and deliberately not persisted: it is a way to read one README, not a preference). The parameterized core exists so **`toggleZoom()` can compare the two states without copying a `Model`** (the `baseFor`/`planFor`/`shellCommand` idiom), which is exactly what it does: below ~82 columns the clamps produce the **same triple for both variants**, so the keypress reports `too narrow to zoom` and flips nothing rather than setting a flag nothing follows — the honest no-op of `toggleGroupByTag`'s refusal, and the reason the status is short (18 cells: `renderStatusBar`'s statusMsg branch does not truncate, so an over-long message is exactly the wrap `TestStatusBarNeverWraps` exists to catch, and this is the one message whose trigger *is* a narrow terminal). `!m.ready` is checked **first and explicitly**, not left to the clamps agreeing by coincidence: before the first `WindowSizeMsg` every width is zero and there is no layout to toggle. A successful toggle reports `readme zoomed` / `layout restored` and returns only the expiry tick — a view toggle fetches nothing. Two consequences are **stated, tested, and accepted**: the wrap width changes (`helpW` stays ≥ 30, off `helpWrapWidth()`'s floor), so `applyLayout` runs `setHelpContent()` and an active `j/k` spotlight in `[3]` is lost; and the README re-renders through glamour synchronously, since the width is part of `readmeRenderCache`'s key. Both are exactly what a width resize already costs. Zoom is orthogonal to the `[3]` update-log takeover — `showsUpdateLog()` owns *content*, zoom owns *width*. +- **`applyLayout()` is the single relayout definition** (model.go, beside `helpWrapWidth`): everything the `tea.WindowSizeMsg` handler used to do after storing `m.width`/`m.height` — the width recompute, `calcListHeight`, the first-time `initViewports(vpH)` arm, the wrap-width-changed `setHelpContent` guard, and the `setToolsContent()` + card repaint tail. The handler is now those two assignments plus the call, so the resize path and `z` cannot drift. **The `prevWrapW` capture must stay above the `calcPanelWidths` assignment**: `helpWrapWidth()` reads the **stored** `m.helpW`, so a capture placed below compares the new width against itself, the re-wrap guard is dead, and `[3]` silently keeps its pre-resize wrapping — with every other test in the package green. `TestApplyLayoutRewrapsHelpOnWidthChange` is what kills that mutation; `TestApplyLayoutIdempotent` kills the one where the extraction drops the `setToolsContent` tail, which no existing resize test observes once the handler stops owning it. - **`panelGutter`**: the blank column a panel keeps between its frame and everything it draws — the `[1]` group headers, every footer, and in `[2]` and `[3]` **all** content, at **both** ends. Content that touches the border it lives in reads as having overflowed it, and on the right the gutter is also what keeps text off the scrollbar thumb. The two wide panels reach it from opposite directions and each through a **single definition**: `[2]` sizes itself to **`cardWidth()`** (`briefW - 1 - 2*panelGutter` — the viewport is one column narrower than the panel, `withScrollbar` keeps that one) and `buildCard` steps the finished card in with **`indentLines`** at the very end; `[3]` wraps to **`helpWrapWidth()`** (the same arithmetic) and `renderHelpContent` is a one-line wrapper applying `indentLines` over `helpContent()`, so the spotlight, the search highlight, the placeholders and the update log all land on the same point and no branch can render flush against the frame. The indent is applied **last, to whole finished lines**: it is plain spaces outside the styling, so it can never split an escape sequence, it lands in front of the metrics plate's own background segments rather than inside them, and it cannot shift a line index — which is why `buildCard`'s clickable-link map, built while writing, stays correct for free. `TestPanelsKeepTheirGutter` pins both panels on rendered output. `[1]` and `[2]` additionally open with a blank **row** for the same reason — their content started against the title spliced into the top border. In `[1]` that row is a real screen line, so it goes into the line maps as a non-selectable one and every tool below it shifts by one (which is what the maps exist for, and `syncToolsViewport` walks *up* over every non-selectable row above the selection so a header and the blank above it are revealed together). Tool rows keep the wider `toolRowIndent` (gutter + marker + blank), which puts the `⏺` in the same column as a section header's label and the names one step in from it. - **Refresh (`r` in `focusBrief`)**: `refreshSelectedCmd(t)` force-refreshes the selected tool bypassing the 24h cache TTL — the repo pass (`refreshRemoteCmd` → `version.RefreshRepoData`) + changelog (`refreshChangelogCmd` → `version.RefreshChangelog`) + README (`refreshReadmeCmd` → `version.RefreshReadme`, preceded by a `delete(m.readmeData, name)` so a session-cached 404/rate-limit negative can recover, then a `markReadmeLoading(name)` — the deletion makes `needsReadme` true again for the whole in-flight window, so without the marker leaving and re-entering the tool would spend a second request; `refreshingFor` does *not* cover it, since `remoteMsg` clears that flag as soon as the repo pass lands, which can be well before the README does) + a local installed re-detect (`fetchInstalledCmd`). It emits the same `remoteMsg`/`changelogMsg` as the startup path, so the merge/re-render logic is reused. While the repo pass is in flight `m.refreshingFor` (the tool name) turns the card title into a status line — `refreshing data ` (`bubbles/spinner`, `MiniDot`; the about is hidden) — with no status-bar takeover; the `remoteMsg` handler clears `refreshingFor` on completion, which reverts the title to name+about and halts the `spinner.TickMsg` loop. **`[r]` answers every press**: on completion the handler returns `setStatus(refreshFailedStatus(msg.err))` whenever **`msg.err != nil`** — every path that actually failed to fetch now carries a named error (see `version.pickFetchErr`), while a pass that fetched something stays silent because the repainted card *is* the answer. The predicate is deliberately **not `!msg.conclusive`**, which is a broader thing: it is also false when the version layer refused the ref outright (an unsupported or spoofed host — a bare `RepoData` with a nil `Err`, no request made), which would report a network failure that never happened, and on a partial pass that fetched a new tag and lost only the repo card, where the bar would contradict a card the user just watched update. Before that, success, a rate limit, a 401, a timeout and a dropped connection were one indistinguishable gesture: the spinner turns, the card does not change. The reason is two-tier — `ErrRateLimited` → `refresh failed: rate limited — press [a]` (the one class with an answer the user can act on), everything else → `refresh failed: network error`. There is deliberately **no token wording**: by the time a fetch fails, `doGH` has already retried a rejected token anonymously, so the refresh did not fail *because of* the token, and the gauge and `[a]` overlay report that state anyway. The write sits inside the `msg.toolName == m.refreshingFor` branch, so the background passes `Init` fires — inconclusive all the time on an offline start — never put a "refresh failed" on the bar for a gesture nobody made. `refreshingFor` doubles as the double-press guard; a tool with no `GitHub` only re-detects the installed version (`m.statusMsg = "no repo to refresh"`, no spinner). Note `case "r"` no longer branches at all: it is refresh in `focusBrief` and unbound everywhere else. Rename went global as `m` and the README source moved to `R`, so the key that once meant three things by focus now means one. - **Update (`enter` in `focusBrief`)** — full rationale in **[`docs/design/updating.md`](docs/design/updating.md)**. Installs a newer release from inside the TUI: `enter` is the card's primary action (in `focusTools` the same key runs the tool), requires `hasUpdate(name)`, and reports the shared `updateBusyStatus` while `updatingFor != ""` — one update at a time, no queue. The guard sequence lives in `startToolUpdate()` (**pointer receiver**, it sets a status message). Detection runs off `Update()` in `detectUpdateCmd(t, false)` because it spawns subprocesses. The invariants most often broken (the full list is in the design file): **`updater.Detect`'s chain order is load-bearing twice** — brew before go, pnpm/bun before npm (both layouts carry `node_modules` segments npm claims on sight, and the misdetection installs a duplicate the shadowed copy hides); **an empty manager root disables its own step, enforced inside `underDir`/`segmentUnder`**, never by a repeated `!= ""` guard (`filepath.Rel("", …)` makes a relative path read as living under every disabled root); all five roots come from `managerDirsFrom` and are **symlink-expanded** by the wrapper, because `Detect` compares them against an `EvalSymlinks`-resolved binary path; pnpm needs the cmd-shim's `# cmd-shim-target=` line and an over-cap file is **rejected whole**, never parsed truncated. `update_cmd` always wins; a `LookPath` miss **and** an exhausted chain both fall back to brew-by-name. `acceptsUpdateDetect(msg)` drops a stale result, and `m.updateTarget` is resolved from the *message*, not from the selection at keypress time. Streaming order is fixed by os/exec: scan the pipe to EOF → `cmd.Wait()` → final `updateLine{done:true, err, elapsed}` → `close(ch)`; a `\r` segment sets `replace`; the log caps at ~500 lines; the deadline path uses `proc.KillGroup` (negative pid); `elapsed` is stamped in `startUpdateCmd`, never in `Update()`, where `time.Now()` would make completion non-deterministic in tests. **`showsUpdateLog()` is the single predicate for who owns `[3]`**. A finished session leaves a **terminal block** under the log — `✓ finished · go · 12s` / `✕ failed · brew · 4s`, then the *verified* version once the post-update `installedMsg` lands (`⚠ fd still v10.2.0` is what catches a manager that exited zero having done nothing), then the way out — and the frame follows it (`[3] update` → `[3] update finished` / `[3] update failed`, words rather than glyphs because `insetPanelTitle` measures runes). It is **model state, not log lines**: the buffer is wrapped at render time and would shred styling. `recordUpdateOutcome` is the single writer, shared with the self path and called on **both** results, so neither the block nor the log format can drift between them. **The phase-2 write in the `installedMsg` handler sits after that handler's cursor remap**, never before: its repaint is gated on `showsUpdateLog()` → `selectedMeta()`, and the version merge above is what re-partitions the list the index reads against, so the pre-remap order both skips the repaint for the tool that just updated and paints its log over the tool sitting at its old row. The **buffer itself renders `Dim`** (`dimUpdateLog`) so the block is the only thing on the panel carrying a verdict; the style lands after the wrap, per whole line, and strips nothing — segments are sanitized at the `updateChunkMsg` boundary. - **Self-update (`U`/`X`)** — full rationale in **[`docs/design/self-update.md`](docs/design/self-update.md)**. keepkit watches its own releases and installs one through the very same pipeline as `enter`. **The feature's main case is a keepkit that is not tracked**, so nothing in this path may read `meta.yaml`, the selection or a card — every guard that normally leans on `selectedMeta()` has a self counterpart that does not. The invariants most often broken (the full list is in the design file): **`selfCheckEnabled()` rejects three shapes**, not one — `""` (no `WithAppVersion`), `"dev"`, and anything `isDevVersion` sees as a working copy (a Go pseudo-version tail or any `+` build metadata), which is what keeps `go build .` from offering to `go install …@latest` over itself; **`isSelfUpdate(name)` = the name *and* that gate**, both clauses load-bearing; the `selfCheckMsg` handler writes **only from `selfNone`**, in either direction, so a late message cannot walk back a state the user acted on; `selfState` has **no "updating" member** — that is derived by `selfUpdating()`; **six sites switch on `selfState`** and each enumerates every member and ends in a `default:`, with the `selfStateCount` sentinel driving `TestSelfStateSitesAreExhaustive`; the failure branch of `updateDoneMsg` writes **no `selfState` at all** (the banner returns by itself once `updatingFor` clears, and any write there could only walk a state back); `[U]` checks `selfNone` **before** the busy guard, or a dev build answers `another update is running` for a surface documented as absent. Restart happens strictly **after `p.Run()` returns** (Bubble Tea has restored the terminal by then), and `resolveSelfPath`'s order matters: an argv0 carrying a separator wins when it exists, a bare argv0 goes through `lookPath` **first** because Linux's `/proc/self/exe` can still name the old binary after an upgrade, and `sameProgram` rejects a `PATH` hit whose base differs. -- **Panel `[3]` modes (`helpMode`)** — full rationale in **[`docs/design/readme-pipeline.md`](docs/design/readme-pipeline.md)**. Three sources: `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool; `[R]`/`[H]`/`[M]` switch it from `focusBrief || focusHelp` through the shared **`switchHelpMode(mode)`** (sets the mode, dismisses a *completed* update log, `setHelpContent()` + `GotoTop()`, returns the fetch command for the mode's missing source). The trio is **capitals as a set** so none collides with a lowercase verb (`r` is `[2]`'s refresh, `m` the global rename). The invariants most often broken (the full list is in the design file): **`m.helpCache` is a `map[string][2]string` indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return *before* the array read; a **live** update log keeps `[3]` in every path (its branch sits ahead of both the readme branch and the `No tool selected` guard). Rendering is `cleanTerminalOutput` → **`cleanReadmeMarkdown`** (readme_clean.go) → glamour with `keepkitStyle`, and a glamour failure falls back to the **preprocessed** text, never to the raw one. In the preprocessor: **code is never rewritten** (fenced blocks segmented, inline spans NUL-masked), CRLF is normalized on entry (a `\r`-suffixed closing fence protects the rest of the file), images run before links, autolinks and bare URLs are left alone, both reference forms are gated on labels collected **document-wide after** the HTML rules, HTML tag names come from the fixed `rcHTMLNames` allowlist whose trailing `\b` is what makes the ~80-branch alternation order-independent, and **`rcLineContent` slices rather than `ReplaceAllString`** — the replace form froze the whole TUI for 8.4 s on a 512 KiB adversarial README. `keepkitStyle` **clones** the glamour globals and assigns a **fresh pointer** per override, because `styles.DefaultStyles` aliases the same structs and writing through a cloned one restyles glamour process-wide. Dark/light is resolved **once at construction** into `m.darkBG` — `glamour.WithAutoStyle()` probes the terminal with an OSC query that races Bubble Tea's input reader. +- **Panel `[3]` modes (`helpMode`)** — full rationale in **[`docs/design/readme-pipeline.md`](docs/design/readme-pipeline.md)**. Three sources: `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool; `[R]`/`[H]`/`[M]` switch it from `focusBrief || focusHelp` through the shared **`switchHelpMode(mode)`** (sets the mode, dismisses a *completed* update log, `setHelpContent()` + `GotoTop()`, returns the fetch command for the mode's missing source). The trio is **capitals as a set** so none collides with a lowercase verb (`r` is `[2]`'s refresh, `m` the global rename). **`z` shares their gate and nothing else**: it is the fourth key that changes what `[3]` is, so it fires from the same `focusBrief || focusHelp` pair, but it changes the panel's *width* rather than its source — so it does **not** move focus (a width change is not a change of what you are reading), goes through `toggleZoom()` rather than `switchHelpMode`, and fetches nothing. See **Panel widths and the zoom toggle** below. The invariants most often broken (the full list is in the design file): **`m.helpCache` is a `map[string][2]string` indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return *before* the array read; a **live** update log keeps `[3]` in every path (its branch sits ahead of both the readme branch and the `No tool selected` guard). Rendering is `cleanTerminalOutput` → **`cleanReadmeMarkdown`** (readme_clean.go) → glamour with `keepkitStyle`, and a glamour failure falls back to the **preprocessed** text, never to the raw one. In the preprocessor: **code is never rewritten** (fenced blocks segmented, inline spans NUL-masked), CRLF is normalized on entry (a `\r`-suffixed closing fence protects the rest of the file), images run before links, autolinks and bare URLs are left alone, both reference forms are gated on labels collected **document-wide after** the HTML rules, HTML tag names come from the fixed `rcHTMLNames` allowlist whose trailing `\b` is what makes the ~80-branch alternation order-independent, and **`rcLineContent` slices rather than `ReplaceAllString`** — the replace form froze the whole TUI for 8.4 s on a 512 KiB adversarial README. `keepkitStyle` **clones** the glamour globals and assigns a **fresh pointer** per override, because `styles.DefaultStyles` aliases the same structs and writing through a cloned one restyles glamour process-wide. Dark/light is resolved **once at construction** into `m.darkBG` — `glamour.WithAutoStyle()` probes the terminal with an OSC query that races Bubble Tea's input reader. - **Help navigation (`j`/`k` in `focusHelp`)**: `[3]` is navigable per *entry* — a flag or subcommand line plus its indented description block. `parseHelpEntries(raw, width)` (textutil.go) detects entries heuristically on the **pre-wrap source lines** (flag start = the `helpTokenRe` flag core at the trimmed line start; subcommand start = `helpEntrySubcmdRe`, an indented non-dash word + 2+ spaces + text — the word class excludes `.` so justified man prose like `tree. See also…` doesn't match; continuation = `continuesEntry`: any deeper-indented non-header line — including deeper lines that *begin* with a flag token (`…overridden with\n --no-ignore.`) — plus blank lines whose next non-blank line still continues, so multi-paragraph descriptions stay one entry; the entry ends at a section header or the next line at the entry's own indent or shallower) and maps the ranges to wrapped display-line indices via `wrapLine` — the same code `wrapText` uses, which is the point: `wrapText` rebuilds wrapped lines from `strings.Fields` (indentation is lost), so parsing wrapped output would break the indent heuristic, and sharing the wrap algorithm plus the single `helpWrapWidth()` (`max(helpW-1-2*panelGutter, 20)` — the viewport is a column narrower than the panel and a gutter is held at each end) keeps entry indices in lockstep with what the viewport shows. `isHelpSectionHeader` is the one definition of a header, used by both `colorizeHelp` and the parser. State is `m.helpEntries []entryRange` + `m.helpNavIdx` (−1 = off) + `m.helpBase` — the wrapped+colorized full-color content, cached because cursor moves repaint per keystroke and must not re-run the colorize regex over a whole man page (`helpContent`'s normal path serves `applySpotlight(helpBase)`, and `renderHelpContent` is the one-line wrapper that steps the result in by `panelGutter`; the base is built directly in `setHelpContent`, not via the renderer, which serves that very base back through `applySpotlight`). **`setHelpContent()` is the single recompute point** — every site where the *visible* text changes (selection via `autoFetchCmdsForSelected`, `[R]`/`[H]`/`[M]`, `helpOutputMsg` — gated on `msg.mode == m.helpMode`, a late fetch for the hidden mode must not reset the cursor, `readmeMsg` for the selected tool while in readme mode, resize — only when `helpWrapWidth()` actually changed, so a height-only resize keeps the cursor and a width change re-renders the README, update-log start) goes through it: recompute entries (empty for the update log, readme mode, `helpLoadingFor` and placeholders — `j`/`k` stay plain scroll there), reset the cursor, repaint, never scroll. Style-only repaints (per-chunk log appends, cursor moves) call `SetContent(renderHelpContent())` directly and must not reset the cursor. Interaction: **only the letter keys navigate — `↑`/`↓` keep their 3-line scroll** so prose between/after entries stays keyboard-reachable; the first `j`/`k` lands via `helpNavStart(delta)` on the first entry intersecting the window, or (none visible) the nearest entry in the movement direction; later presses step clamped without wrap; `applySpotlight` (render.go) dims every line outside the current entry (`Styles.Dim.Render(stripANSI(line))` — the `[a]` overlay's strip-then-repaint trick per whole line) while the entry keeps full `colorizeHelp` color; `scrollToNavEntry` keeps it in view with mutually exclusive branches and a `min(end-Height, start)` clamp so a taller-than-window entry pins its start to the top. `esc` is two-stage: cursor off first (scroll kept), focus walk second. `PgUp`/`PgDn`/`g`/`G`/wheel stay pure scroll and never touch the cursor. Every path that deactivates navigation (esc, any `setFocus` move) goes through `clearHelpNav()`, which pairs the reset with the repaint — clearing the index without repainting leaves stale dimming. The `focusHelp` bar shows `[j/k] navigate` alongside `[↑↓] scroll` when entries exist and prepends `[esc] exit nav` while the cursor is on. - **Tracking verbs are global**: `t` track (add by GitHub URL or plain name → `modeTrack`), `u` untrack (with confirmation → `modeConfirmUntrack`), `m` rename (fix the binary name when the repo name differs → `modeRename`). All three fire in **every focus** and are three of the six keys `globalHints` puts on the status bar. They used to be `[1]`-only because each collided with a `[2]` action (`t` tags, `u` update, `r` refresh); the redesign moved those onto keys of their own (`#` tags, `enter` update) and freed all three to mean one thing everywhere, which is what lets the bar carry a single focus-independent list instead of three per-focus ones. Rename is **`m`, not `R`**: `R` is panel `[3]`'s readme source now, and the tracker's verbs are the lowercase set. That leaves lowercase `r` meaning **refresh in `[2]` and nothing else** — it used to double as `[3]`'s readme switch, so one key meant "spend three requests" or "swap the panel's source" depending on a focus the `[3]` title did not mention. Each mode has a handler in `mode.go` and a matching branch in `renderStatusBar()`, mirroring the `modeEditNote`/`modeEditTags` input pattern. Mutations go through `loader.UpsertMeta`/`RemoveMeta`, persist via `loader.SaveMeta`, then rebuild `m.tools = loader.ToolsFromMeta(m.meta)` and refresh the viewport. - **Run (`enter` in `focusTools`)**: launches the selected tool without leaving keepkit. `enter` fires only in `modeNormal`+`focusTools` (empty list → no-op; in `modeSearch` enter stays the commit key) and opens `modeRunInput` — a one-line prompt (`m.runInput`, its own textinput like `m.search`, not shared with note/tags) prefilled with `m.lastRun[name]` else the tool name, cursor at end; the status bar echoes `run : [enter] run [esc] cancel`. `m.lastRun map[string]string` is session-only per-tool memory of the last dispatched command — rename's stale-state cleanup deletes the old-name entry alongside `helpCache` et al.; untrack deliberately leaves it (harmless, session-scoped). Enter with empty/whitespace input cancels like `esc`. On dispatch `launcher.Detect(command, name)` picks the path — env-only, so unlike every probe it is safe inside `Update()`: a tab plan runs its `Argv` via `startLaunchCmd` (`exec.Command` + `proc.DetachTTY`, `launchTimeout` — a 10s **var**, shrunk by the timeout/KillGroup test — with `proc.KillGroup` on expiry, `safeCmd`-wrapped) → `launchDoneMsg{toolName, command, err}`, with `m.launchingFor` (the launch twin of `updatingFor`) as the one-adapter-launch-at-a-time guard and a `launching in …` statusMsg as in-flight feedback (this is also `Plan.Terminal`'s consumer); a `Fallback` plan runs `execToolCmd` → `tea.ExecProcess` over `shellCommand(runtime.GOOS, cmd)` (`sh -c` / `cmd /c`; goos-parameterized and spawn-free like `browserCommand`, so both branches are table-testable) — keepkit suspends, Bubble Tea restores the terminal when the tool exits → `execDoneMsg{toolName, err}`. **Auto-fallback**: an adapter failure (kitty remote control off, Automation permission denied) must not strand the launch — the `launchDoneMsg` error handler sets `statusMsg` (`tab open failed — running here`) and returns `execToolCmd(msg.toolName, msg.command)`; `command` rides the msg so the handler never re-reads input state. The auto-fallback is **gated on `modeNormal`**: the result can arrive up to `launchTimeout` after enter (osascript blocked on the macOS Automation dialog), and `tea.ExecProcess` seizing the terminal under an open editor/overlay would route keystrokes to the spawned shell — under any other mode the fallback is **deferred**, not dropped: the gate stores `m.pendingLaunchName`/`Command` and `flushPendingLaunch` (mode.go) dispatches `execToolCmd` with the same statusMsg (single definition: `launchFallbackStatus`, shared with the ungated auto-fallback) on the keystroke that returns the mode to `modeNormal` (every modal return in `Update` funnels through it — the mode-dispatch switch plus the inline `modeSearch` exit; `modeTokenInput`'s esc lands on `modeAPIStatus`, so the flush waits for the overlay to actually close). A statusMsg set at gate time would be dead UI — every open mode's `renderStatusBar` branch outranks the statusMsg branch, and the blanket `statusMsg = ""` reset on `tea.KeyMsg` fires on the very keystroke that closes the mode; setting it in the flush (after both) is what makes the failure visible. The flush goes straight to `execToolCmd` — never back through `launcher.Detect` — so a known-failing adapter plan is never re-run; a new dispatch from `modeRunInput`'s enter drops a pending fallback first (flushing both on one keystroke would run two commands), and confirming untrack of the pending tool itself drops it too — the dialog-closing enter must not exec the now-untracked tool's command (a pending fallback for a *different* tool deliberately survives the untrack and flushes on that keystroke). The handler clears `launchingFor` first in all outcomes. Working directory differs by path: a tab opens in the new shell's default cwd, the ExecProcess fallback inherits keepkit's. The timeout carries one accepted race: an adapter killed after its tab command already executed (osascript stuck post-`write text`) still triggers the fallback, so the command can run twice — narrow, undetectable, and better than stranding genuine failures. This path also serves native Windows: `planFor` is env-only, so WezTerm there yields a doomed `sh -c` plan whose failure lands in the fallback (one noisy attempt accepted; a `GOOS` guard in `planFor` is deliberate YAGNI). Success wording is mode-neutral — `launched ` — because Terminal.app and tmux open a *window*, not a tab. **No `logx` anywhere in the flow**: a non-zero tool exit (`statusMsg " exited: "`) is the tool's business, not a keepkit anomaly, and an adapter error is a degraded path, not a malfunction (the auto-fallback still launches the tool). Launch during a running update is deliberately not blocked — independent concerns; ExecProcess pauses rendering of the live update log and the buffer catches up on resume. A not-installed tool launches anyway (no PATH pre-check): in a tab `sh` reports `command not found` inside that tab, while on the ExecProcess path the shell's not-found exit (`notFoundExit`: 127 sh / 9009 cmd.exe) maps to `statusMsg " not found — is it installed?"` instead of the cryptic raw exit status. The `[?]` overlay's tools group carries `enter — run in tab` (desc kept short — the overlay sits at the 76-col edge of its budget). diff --git a/README.md b/README.md index 1e2f580..e515486 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,10 @@ Run `keepkit` — a three-panel interface opens: before rendering — badges, logos, HTML and pictographic emoji go, link text stays without its URL, the title (and a slogan under it that only repeats the card's own) is dropped so the panel opens on the first sentence that says something new, and - code blocks are untouched. While an update runs, this panel shows its live log + code blocks are untouched. `z` widens this panel at the card's expense and back — + from `[2]` or `[3]`, the same two focuses `R`/`H`/`M` fire from, since it is another + key that changes what this panel is; on a terminal too narrow for the split to move + it says so instead. While an update runs, this panel shows its live log instead, and keeps it afterwards under a line saying how the update ended. Focus moves with `←` / `→` or the digits `1` / `2` / `3` (each panel's number is in diff --git a/docs/design/readme-pipeline.md b/docs/design/readme-pipeline.md index 0b0bc39..946efd5 100644 --- a/docs/design/readme-pipeline.md +++ b/docs/design/readme-pipeline.md @@ -6,7 +6,7 @@ Read this before touching `helpMode`/`switchHelpMode`, `internal/model/readme.go `readme_clean.go` or `readme_style.go`. The live update log that can take the panel over is described in [`updating.md`](updating.md). -- **Panel `[3]` modes (`helpMode`)**: three sources — `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool: `[R]`, `[H]` and `[M]` switch it, all three from `focusBrief || focusHelp`. The three are **capitals as a set** so that none of them collides with a lowercase verb — `r` is `[2]`'s refresh and `m` the global rename — and so that the trio is uniform: readme used to be lowercase `r` and `focusHelp`-only, which meant the `[3]` title advertised `r readme` while pressing it in `[2]` silently spent three requests on a refresh instead. All three go through the shared **`switchHelpMode(mode)`** (model.go), which sets the mode, dismisses a *completed* update log (a tool's via `updateLogFor`, keepkit's own via `dismissSelfLog()` — the latter ahead of the `selectedMeta` guard), calls `setHelpContent()` + `GotoTop()` and returns the fetch command for the mode's missing source (README via `needsReadme`, `--help`/`man` via the `helpCache` miss + `helpLoadingFor`); focus stays with the caller, because all three also fire from `[2]` and move focus to `[3]` with them. A **live** update log keeps `[3]` in every path (the log branch sits ahead of the readme branch *and* the `No tool selected` guard in `renderHelpContent`, `setHelpContent` gates on `showsUpdateLog()`, and the readme case in `autoFetchCmdsForSelected` sits after that same case). **`m.helpCache` is a `map[string][2]string` whose values are indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` instead and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return/branch *before* the array read; `helpOutputMsg` indexes `msg.mode`, which only ever carries help/man. Rendering: `renderReadme` (readme.go) is a three-stage pipeline — `cleanTerminalOutput` (the same sanitizer every probe capture gets) → **`cleanReadmeMarkdown`** (readme_clean.go, the house-style preprocessor) → glamour with **`WithStyles(keepkitStyle(t, dark))`** (readme_style.go), `WithWordWrap(helpWrapWidth())`, `WithColorProfile(lipgloss.ColorProfile())` (glamour hardcodes TrueColor and would ignore `NO_COLOR`/dumb terms) and `WithInlineTableLinks(true)` (a table link in its cell, not a numbered footnote under the table). Dark/light is still resolved **once at construction** into `m.darkBG` via lipgloss's cached `HasDarkBackground()`, because `glamour.WithAutoStyle()` probes the terminal with a termenv OSC query that reads stdin and races Bubble Tea's input reader; what changed is that the answer now picks a *StyleConfig* rather than a style *name*, so `readmeStyleName` is gone and `renderReadme` branches on `testReadmeStyle != ""` (→ `WithStandardStyle`) directly — the seam's only job is to make the constructor fail. A glamour failure falls back to the **preprocessed** text (not the merely sanitized text), or a failed render would be the one path that still shows badge and href noise; never an empty panel. An input that cleans down to whitespace returns `""` early, which is what routes a badge-only README to the placeholder below. +- **Panel `[3]` modes (`helpMode`)**: three sources — `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool: `[R]`, `[H]` and `[M]` switch it, all three from `focusBrief || focusHelp` — the gate `z` (the panel-width toggle, `toggleZoom` in model.go) shares, being the fourth key that changes what `[3]` is; it changes no source, so it never touches `helpMode`, but it does change `helpWrapWidth()`, which puts the README through this whole pipeline again (the width is part of `readmeRenderCache`'s key) and resets the entry spotlight — the same cost a width resize already has. The three are **capitals as a set** so that none of them collides with a lowercase verb — `r` is `[2]`'s refresh and `m` the global rename — and so that the trio is uniform: readme used to be lowercase `r` and `focusHelp`-only, which meant the `[3]` title advertised `r readme` while pressing it in `[2]` silently spent three requests on a refresh instead. All three go through the shared **`switchHelpMode(mode)`** (model.go), which sets the mode, dismisses a *completed* update log (a tool's via `updateLogFor`, keepkit's own via `dismissSelfLog()` — the latter ahead of the `selectedMeta` guard), calls `setHelpContent()` + `GotoTop()` and returns the fetch command for the mode's missing source (README via `needsReadme`, `--help`/`man` via the `helpCache` miss + `helpLoadingFor`); focus stays with the caller, because all three also fire from `[2]` and move focus to `[3]` with them. A **live** update log keeps `[3]` in every path (the log branch sits ahead of the readme branch *and* the `No tool selected` guard in `renderHelpContent`, `setHelpContent` gates on `showsUpdateLog()`, and the readme case in `autoFetchCmdsForSelected` sits after that same case). **`m.helpCache` is a `map[string][2]string` whose values are indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` instead and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return/branch *before* the array read; `helpOutputMsg` indexes `msg.mode`, which only ever carries help/man. Rendering: `renderReadme` (readme.go) is a three-stage pipeline — `cleanTerminalOutput` (the same sanitizer every probe capture gets) → **`cleanReadmeMarkdown`** (readme_clean.go, the house-style preprocessor) → glamour with **`WithStyles(keepkitStyle(t, dark))`** (readme_style.go), `WithWordWrap(helpWrapWidth())`, `WithColorProfile(lipgloss.ColorProfile())` (glamour hardcodes TrueColor and would ignore `NO_COLOR`/dumb terms) and `WithInlineTableLinks(true)` (a table link in its cell, not a numbered footnote under the table). Dark/light is still resolved **once at construction** into `m.darkBG` via lipgloss's cached `HasDarkBackground()`, because `glamour.WithAutoStyle()` probes the terminal with a termenv OSC query that reads stdin and races Bubble Tea's input reader; what changed is that the answer now picks a *StyleConfig* rather than a style *name*, so `readmeStyleName` is gone and `renderReadme` branches on `testReadmeStyle != ""` (→ `WithStandardStyle`) directly — the seam's only job is to make the constructor fail. A glamour failure falls back to the **preprocessed** text (not the merely sanitized text), or a failed render would be the one path that still shows badge and href noise; never an empty panel. An input that cleans down to whitespace returns `""` early, which is what routes a badge-only README to the placeholder below. - **The preprocessor (`cleanReadmeMarkdown`)** exists because a README is written for a browser: badges, logos, `` wrappers, hrefs nobody can click in a TTY, emoji a terminal font renders as tofu. It is pure and rests on one inviolable rule — **code is never rewritten**, since a fenced block and an inline span are exactly how a README *shows* the markup these rules delete. So `rcSegments` splits the input into protected fenced blocks (opener = 3+ backticks or tildes at any indent — CommonMark's 3-space limit only ever mis-read a fence nested under a list item; closer = the **same character**, a run **at least as long**, nothing but whitespace after, which keeps a ```` ``` ````-wrapping fence one block; an **unterminated fence protects to EOF**, because `version.getReadme` truncates at `readmeMaxBytes` and the cut can land mid-fence) and the cleanable runs between them, and inline spans are **masked** inside a cleanable segment (`rcMaskSpans`, a NUL-bracketed placeholder — NUL is the sentinel precisely because `cleanTerminalOutput` ran first and drops every control character). Masking rather than segmenting is deliberate: the block-level rules (a multi-line HTML comment, a `` body) need the segment to stay one string, and a span that *contains* `` would gate the unwrapping and eat the very brackets the gate protects. Standalone `[label]: url` definition lines are dropped as the pure metadata they now are, under **two** guards, because deleting a line of someone's README is the most destructive thing this pass does: the destination must be a single token (or ``) with at most a quoted title, and the line **may not interrupt a paragraph** (CommonMark forbids that anyway) — without them `[1]: first item explained` and a `[note]: this matters` sitting mid-paragraph both silently vanished. *HTML*: comments whole; ``/`