Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -276,8 +278,22 @@ Key invariants:
`[3]` is *all* content: `cardWidth()` and `helpWrapWidth()` are the single definitions
of the two budgets, and `indentLines` applies the indent last, to whole finished
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.
`calcListHeight()` is the single definition both `applyLayout` 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. That refusal gates **activation only**, like
`toggleGroupByTag`'s — a session zoomed wide and then resized narrow must still be
able to turn zoom off, or the flag is stranded on and the layout returns zoomed the
moment the terminal grows. `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.
- **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
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/design/readme-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<picture>` 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 `<picture>` body) need the segment to stay one string, and a span that *contains* `<!--` must still survive. The span search stops at a blank line, mirroring CommonMark's paragraph boundary — one stray backtick would otherwise pair with another three sections later and exempt half the README from cleaning. Known limit, documented rather than fixed: two spans separated by *nothing* but a removed construct come back adjacent (`` `x`![i](u)`y` `` → `` `x``y` ``), which CommonMark then reads as one span — it needs zero whitespace on either side, so no real prose hits it, and fixing it means inventing a separator the author did not write. **CRLF is normalized on entry** for `markdownToLines`' reason and one of its own: a `` ```\r `` closing fence never matches, so the first fence would protect everything to EOF and nothing would be cleaned at all. Production is safe without it (`cleanTerminalOutput` drops `\r` first), but every test calls `cleanReadmeMarkdown` directly — which is exactly the path that broke. **4-space indented code blocks are not protected** (an accepted limitation: they are ambiguous with nested-list continuation lines, and modern READMEs use fences).
- **Removal rules**, all package-level regexes (the `helpTokenRe` idiom — this runs synchronously inside `Update()` on up to 512 KiB). *Images* go whole in every form (`![alt](url)`, reference `![alt][ref]`/`![alt][]`, and the shortcut `![alt]`), which needs no badge-vs-logo heuristic for the simple reason that **no image can ever render in a TTY**. A *linked* badge collapses for free: image removal turns `[![alt](img)](target)` into `[](target)` and the link unwrap then empties it, which is why images run first. *Links* are unwrapped to their text — panel `[3]` links are not clickable, so the href half is pure noise — and **autolinks and bare URLs are left alone**, since there the URL *is* the content (this is also why the style-level route was rejected: glamour renders both through the same `Link` primitive, so blanking it deletes them). The inline destination pattern is shared with the card's converter as **`mdDest`**, which allows **one level of nested parentheses**: a flat `[^)]*` stopped at the inner `)` of a shields.io badge (`…/badge/a-(b)-blue.svg`) or a Wikipedia link (`…/wiki/Foo_(bar)`) and left the rest of the URL on screen as text — exactly the noise the pass exists to remove. Deeper nesting needs recursion, which RE2 does not have. **Both reference forms are gated on a declared label** — `[text][ref]` and the shortcut `[label]` alike, collected **document-wide** because definitions collect at the bottom of a README while their uses sit at the top and a fence between them puts the two in different segments. Ungated, the rule ate a task list's `[x]`, a prose `[experimental]`, and — the reference form's own case — `arr[i][j]` in prose, which came out as `arri`. The label set is built **after** the HTML rules run, or a definition commented out with `<!-- -->` 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 `<angled>`) 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; `<picture>`/`<video>`/`<audio>`/`<svg>`/`<script>`/`<style>` whole **including bodies** (one regex per name — RE2 has no backreference); every other tag stripped keeping its inner text (`<kbd>Ctrl</kbd>` → `Ctrl`, a `<details>`/`<summary>` block keeps its content). The tag name is matched against a **fixed allowlist** (`rcHTMLNames`), not a generic identifier shape — a shape-based pattern cannot tell `<kbd>` from `Vec<String>`, and it is the allowlist that lets `<https://…>`, `<user@host>`, `Vec<String>` and `a < b` through; an unknown name is left as written, the honest degradation, and no general sanitizer (bluemonday) is pulled in. Two details there are load-bearing: the trailing **`\b`** after the name is what makes the ~80-branch alternation order-independent (Go's regexp is leftmost-*first*, so without it `a` would claim `<abbr>` and leave `br>` behind), and the attribute part (**`rcTagAttrs`**) matches quoted values whole, since a flat `[^<>]*` ends the tag at the first `>` — including one inside a value, which left `<img alt="a > b" src="x.png">` rendering as ` b" src="x.png">`. *Emoji*: the pictographic SMP blocks U+1F000–U+1FAFF plus the joiners that would be stranded without them (VS16 U+FE0F, ZWJ U+200D, the combining enclosing keycap U+20E3, which is what makes `1️⃣` leave a bare `1`); BMP symbols a terminal font does carry (`✓ ★ →`, and `✨`, `⭐`, `❗`) are kept, and `✅→✓` / `❌→✗` / `☑→✓` are translated rather than dropped, because a feature table's meaning lives in that column. *Shortcodes* (`:name:`) go only when `name` is in **`definition.Github()`** — goldmark-emoji's dictionary, the same one glamour gets, promoted to a direct dependency for this and adding nothing to `go.sum` — so `:30:` inside `12:30:45` and an unknown `:foo:` survive.
- **Post-removal tidy-up** is per line and **gated on the line having actually changed**: interior double spaces collapse, the tail is trimmed, and a line whose content removal emptied is dropped (`rcLineContent` strips leading block markers first, so `## 🚀` — which cleans to `##`, a heading with no content glamour would paint as a styled blank row — is recognized as empty). The gate is what keeps an untouched `- - -` thematic break from being mistaken for an emptied bullet. A **hard line break survives explicitly**, not by the gate: `rcTidyLine` carries two trailing spaces over when the original had them, because that is markup and a removal inside the line has no business retiring it. The **indent comes from the original line**, or a leading `🚀 ` would leave an indent the author never wrote — four of them and markdown reads the line as an indented code block. A dropped line takes an **orphaned setext underline** with it (the `=` form only; a `-` run is equally a thematic break and renders as a rule either way), or removal of the title left a visible row of equals signs behind. Runs of blank lines then fold to one (in markdown one break and five are the same break), which is what makes a ten-badge header vanish instead of leaving ten empty rows.
Expand Down
Loading
Loading