diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2007e85..634ad45 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ graph TD | `internal/logx` | Session error journal: errors only, one lazily created file per session, imports only the stdlib-only `configdir` leaf. Package-level state — any package can log without threading a logger through | | `internal/model` | The entire Bubble Tea model: TUI state, key handling, rendering | | `internal/proc` | `DetachTTY` — run probes without a controlling terminal; `KillGroup` — process-group SIGKILL (plain `Kill` on Windows) | -| `internal/ui` | `Theme` — the app's ten semantic color roles — and `Styles`, the whole style set built from one theme by `NewStyles`; `PlaceOverlay`, `StripANSI` | +| `internal/ui` | `Theme` — the app's ten semantic color roles — and `Styles`, the whole style set built from one theme by `NewStyles`; the two non-role palettes (`LanguageColor`, and `HeadingColors`/`ChromaColors` for panel `[3]`); `PlaceOverlay`, `StripANSI` | | `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` | | `internal/version` | Detect the installed version locally — `InstalledVersion(t) (ver, present)`; GitHub API with a 24-hour cache; semver comparison (`IsNewer`) and the card's version spelling (`DisplayVersion`); keepkit's own release check (`SelfRepo`, `SelfLatest`) | @@ -76,9 +76,9 @@ The `model` package is split across files within a single package: | `mode.go` | The `inputMode` enum and a handler per input mode | | `commands.go` | All `tea.Cmd` constructors (fetch commands, update streaming) and re-fetch predicates | | `render.go` | `View`, panel/card/status-bar/gauge/overlay renderers, mouse handling. The two list/card builders return their line index alongside the text: `buildCard` → clickable lines, `buildToolRows` → the tool-index ↔ screen-line maps. Carries the single-entry `changelogRenderCache` — the card is rebuilt on every spinner frame, so the release-notes conversion must not repeat | -| `readme.go` | `renderReadme` — panel `[3]`'s pipeline: sanitize → preprocess → glamour, with a single-entry render cache | +| `readme.go` | `renderReadme` — panel `[3]`'s pipeline: sanitize → preprocess → glamour, with a single-entry render cache; `chromaFormatterFor` maps the color profile onto chroma's formatter so a code fence's plate is not quantized away from the card's | | `readme_clean.go` | `cleanReadmeMarkdown(text, about)` — the pure README preprocessor. Fenced blocks and inline spans are segmented out first (code is never rewritten), then badges, hrefs, HTML and emoji are removed from what is left, and finally the leading H1 (plus a slogan under it that repeats `about`) is dropped as a title page the card already shows | -| `readme_style.go` | `keepkitStyle(theme, dark)` — panel `[3]`'s glamour theme: the standard config cloned, its accents replaced from the `ui.Theme` it is handed | +| `readme_style.go` | `keepkitStyle(theme, dark, width)` — panel `[3]`'s glamour theme: the standard config cloned, its *chrome* re-accented from the `ui.Theme` it is handed and its *typography* (heading ladder, fence accents) taken from `ui`'s fixed README palette; `width` sizes the full-width divider | | `textutil.go` | Pure text helpers (`wrapText`, `stripANSI`, `colorizeHelp`, `parseHelpEntries`, `markdownToLines` — the card's release-notes markdown → pre-wrapped tagged lines, …) | | `browser.go` | Opening URLs per `GOOS` | @@ -229,8 +229,8 @@ Key invariants: it. The same rule governs the language band under it. - **The language stack is a picture; the fields below it are not.** A distribution gets `languages · ● go 99% · ● shell 1%` and a full-width band under it holding the - same shares in GitHub's own colors (`ui.LanguageColor`, the one palette a theme - switch must not repaint), closed by a blank row so the band reads as an edge rather + same shares in GitHub's own colors (`ui.LanguageColor`, one of the two palettes a + theme switch must not repaint), closed by a blank row so the band reads as an edge rather than an underline. `status`, `tags` and `note` share one wrapped line under it, broken between whole cells — a cell carries ANSI, so a cut inside one would emit a broken escape into a viewport that re-emits its content verbatim. @@ -239,9 +239,16 @@ Key invariants: model carries the result in `m.styles`, read through `m.sty()`. No file below `internal/ui` carries a hex literal, and `ui.DefaultStyles()` exists only as the fallback for a `Model{}` literal — a renderer that reached for it directly would keep - painting the default palette after a theme switch. The one deliberate exception is - `ui.LanguageColor`: those are linguist's brand marks rather than keepkit's meanings, - so they live in the same package but outside `Theme` and never follow a switch. + painting the default palette after a theme switch. There are exactly **two** + deliberate exceptions, both in `internal/ui` but outside `Theme`, and they are + exceptions for different reasons. `ui.LanguageColor` holds linguist's brand marks + rather than keepkit's meanings, so keepkit has no standing to restyle them. + `ui.HeadingColors`/`ui.ChromaColors` (readme_palette.go) are keepkit's *own* + invented shades for panel `[3]`'s heading ladder and code-fence accents — a + contained break of "a role names a meaning, not a shade", taken because a heading + *level* is not a meaning `Theme` has a word for and six levels cannot be six roles. + Neither follows a theme switch, so `[3]`'s headings and fence accents stay put while + its body, links, quotes and inline code repaint. - **Card links are indexed, not parsed.** `buildCard()` returns the card text plus a `line → URL` map recorded while writing (line heights vary with wrapping), so a click on the title line (which carries the bare `owner/repo` beside the name, linked as the diff --git a/CLAUDE.md b/CLAUDE.md index e65ac82..2358eb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Everything else stays here. **Never re-inline these three sections.** When a cha | `internal/logx` | Errors-only session logger (imports only the stdlib-only `configdir` leaf); one lazily-created plain-text file per session under `/keepkit/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors | | `internal/model` | Entire Bubble Tea model — all TUI state, key handling, and rendering | | `internal/proc` | `DetachTTY` — runs tool probe subprocesses without a controlling terminal (`Setsid` on unix, `DETACHED_PROCESS` on Windows); `KillGroup` — process-group SIGKILL (negative pid; plain `Process.Kill` on Windows) for the update streamer's timeout path | -| `internal/ui` | `Theme` (theme.go) — the app's ten semantic color roles plus the gauge's dim track — and `Styles` (styles.go), every lipgloss style keepkit renders with, built from one theme by the single `NewStyles(Theme)`. `DefaultStyles()` is the fallback for a `Model{}` literal and nothing else. `LanguageColor` (lang.go) is the one thing here that is **not** a theme role and must not follow a theme switch: linguist's per-language brand colors, keyed case-folded, `(color, false)` for a language it does not know. Plus `PlaceOverlay` (which takes the dim style rather than reading a package var) and `StripANSI` | +| `internal/ui` | `Theme` (theme.go) — the app's ten semantic color roles plus the gauge's dim track — and `Styles` (styles.go), every lipgloss style keepkit renders with, built from one theme by the single `NewStyles(Theme)`. `DefaultStyles()` is the fallback for a `Model{}` literal and nothing else. **Two** palettes here are **not** theme roles and must not follow a theme switch, and they are exceptions for different reasons: `LanguageColor` (lang.go) is linguist's per-language brand marks, keyed case-folded, `(color, false)` for a language it does not know — somebody else's colors, so keepkit may not restyle them; `HeadingColors`/`ChromaColors` (readme_palette.go) are keepkit-*invented* shades for panel `[3]`'s heading ladder and code-fence accents, a contained exception to "color is a role, not a shade" taken because a heading *level* is not a meaning `Theme` has a word for and six levels cannot be six meanings. The consequence is stated in all three of theme.go, readme_palette.go and `keepkitStyle`: a theme switch repaints `[3]`'s body, links, quotes and inline code and **not** H1–H5 or the fence accents. `HeadingColors` **descends** — bright H1 to dark H5 — and `TestHeadingColorsDescend` pins that direction, because reordering it is a one-line edit that breaks nothing else. Plus `PlaceOverlay` (which takes the dim style rather than reading a package var) and `StripANSI` | | `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` (brew → go → cargo → pipx → uv → pnpm → bun → npm chain; `update_cmd` override always wins; on a `LookPath` miss **or an exhausted chain**, a brew-by-name fallback before giving up — see [`docs/design/updating.md`](docs/design/updating.md)). Bottom of the import graph like `version`: no TUI knowledge, depends only on `loader` for `Tool`. Pure `detectFromPath`/`brewNamePlanAt`/`managerDirsFrom`/`pnpmShimTarget` cores + OS-facing `Detect`/`brewNamePlan`/`resolveManagerDirs`/`readPnpmShim` wrappers, plus the wrapper-less goos-parameterized `customPlan(goos, cmd)` (`update_cmd` → `sh -c`, `cmd /c` on Windows so a winget/PowerShell command needs no Git Bash) — **a deliberate duplicate of `model.shellCommand`** for `testBrewPrefix`'s reason (`updater` sits below `model` and may not import it); the cross-reference comments on both copies are the only drift guard, and both carry the same accepted caveat — Go's argv quoting is not cmd.exe-aware, so an `update_cmd` embedding double quotes can misparse under `cmd /c`. Seams: `testHomeDir`, `testBrewPrefix` (a deliberate duplicate of `version`'s — two bottom leaves that may not import each other) and `testGOOS`, which exists because **CI runs `go test` on linux only** (the Windows job cross-compiles), so an expectation derived from `runtime.GOOS` asserts nothing about the Windows branch — `TestDetectUpdateCmdOverride` drives the seam per row and is what catches `Detect` passing a literal goos, the regression this package shipped once | | `internal/version` | Detect installed version locally — `InstalledVersion(t) (ver string, present bool)`, the two results independent so the card can tell "installed but won't say its version" from "not installed". Sources in order: `--version`/`-V`, then `brewDirVersion` in `brew.go` (reads the version from the `Caskroom/`/`Cellar/` directory names — no brew subprocess — so casks with no version CLI still resolve), then `cargoListVersion`/`cargoVersionFromList` (same idea one ecosystem over: `cargo install --list` names every cargo-installed crate's version without running its binary; gated on the binary existing, and `LookPath("cargo")` short-circuits before any subprocess). A fallback hit suppresses the anomaly log; `testBrewPrefix` seam. Also: fetch latest release, repo card, changelog and README from the GitHub API with a 24h cache; semver comparison (`IsNewer`) and the card's version spelling (`DisplayVersion`); keepkit's own self-check (`selfcheck.go`: `SelfRepo`, `SelfLatest`) | @@ -59,7 +59,7 @@ The `model` package is split by responsibility (one package, several files): | `render.go` | `View`, panel/card/status-bar/gauge/overlay renderers, scrollbar, mouse handling | | `readme.go` | `renderReadme` — panel `[3]`'s pipeline (sanitize → preprocess → glamour) + the single-entry `readmeRenderCache`; `testReadmeStyle` seam | | `readme_clean.go` | `cleanReadmeMarkdown(text, about)` — the pure README preprocessor: fenced-block/inline-span segmentation first, then the image/link/HTML/emoji/shortcode removal rules on what is left, then `rcDropTitleBlock` | -| `readme_style.go` | `keepkitStyle(t ui.Theme, dark)` — panel `[3]`'s glamour theme, cloned from the standard config and re-accented from the theme it is handed (including a **bounded repaint of `CodeBlock.Chroma`** — a fence renders through chroma on any color terminal and through the `StyleBlock` fields only on an Ascii one, so both paths carry the plate) | +| `readme_style.go` | `keepkitStyle(t ui.Theme, dark bool, width int)` — panel `[3]`'s glamour theme, cloned from the standard config; the **chrome** re-accented from the theme it is handed, the **typography** (H1–H5 ladder + `›` depth markers, and five code-fence tokens) from `ui.HeadingColors`/`ui.ChromaColors`. `width` is the caller's **clamped** wrap width and is spent on one thing: the full-width `HorizontalRule.Format`. Includes the **repaint of `CodeBlock.Chroma` bounded to a named seven** — a fence renders through chroma on any color terminal and through the `StyleBlock` fields only on an Ascii one, so both paths carry the plate | | `textutil.go` | Pure text/format helpers (`wrapText`/`wrapLine`, `stripANSI`, `colorizeHelp`, `parseHelpEntries`, `markdownToLines`/`mdInline` — the card's release-notes converter, `formatStars`, `renderLangList`/`renderLangBand`/`formatShare` — the card's language block, …) | | `browser.go` | `openURLCmd` + per-`GOOS` `browserCommand` | @@ -112,7 +112,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **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). **`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. +- **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. In the theme itself: the heading ladder and the fence accents come from `ui.HeadingColors`/`ui.ChromaColors` and **do not follow a theme switch** (the contract change, stated in three places); **`H1.Prefix = ""`/`H2.Prefix = ""` are load-bearing assignments** — glamour's cascade overrides a child prefix only when it is non-empty, so losing them brings the stock `# `/`## ` markers back rather than falling through to `Heading`; **`H6.Bold` must be set explicitly** (the stock dark H6 is an explicit `Bold: false` that beats inheritance); in the chroma repaint **`Text.BackgroundColor` is what carries the whole plate** — chroma resolves an unset token background up to `Text`, its root, not to `Background`, so the five accents set `Color` only; and `HorizontalRule.Format` is guarded on `width > 0`, since a caller with no layout yet must get the stock rule rather than an empty string. **`chromaFormatterFor`** (readme.go) is what keeps a fence's plate the same color as the card's: glamour's default formatter is `terminal256` for *every* profile, ignoring the `WithColorProfile` beside it, which quantized `Surface` `#343945` to index 237 and put one plate on screen in two colors. Two accepted limits stay: chroma's plate covers only the highlighted **tokens** — glamour paints a code block's left indent with the *enclosing* block's style and never paints the pad out to the wrap width, so a wrapped fence line reads as a short smudge — and `TestFencePlateMatchesTheCard` allows one unit per channel, because **termenv** rounds `#343945` to `52;56;69` while chroma writes the hex verbatim. **Any override new to this file must be confirmed against glamour's renderer once before its struct test is written** — a struct assertion only proves the field changed, and `Item.Color` passes one while doing nothing. - **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/docs/design/readme-pipeline.md b/docs/design/readme-pipeline.md index 946efd5..b6241db 100644 --- a/docs/design/readme-pipeline.md +++ b/docs/design/readme-pipeline.md @@ -11,4 +11,26 @@ is described in [`updating.md`](updating.md). - **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 ``) 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; ``/`