diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62d7354..a794416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,15 @@ jobs: - name: Vet run: go vet ./... + # The runner is linux-only, so the build-tagged files for the other two + # platforms (restart_windows.go, restart_unix.go, proc/detach_windows.go) + # would never be compiled by any automated check and could rot silently. + - name: Cross-compile (windows, darwin) + run: | + GOOS=windows go build ./... + GOOS=windows go vet ./... + GOOS=darwin go build ./... + - name: Test run: go test -race ./... diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea9e67b..650ae4b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,10 +2,20 @@ `keeptui` is a terminal TUI tracker for CLI tools built with [Bubble Tea](https://github.com/charmbracelet/bubbletea). It is a pure TUI: `main.go` is a thin launcher that reads the tracker (`loader.LoadMeta()`), -sets up the error journal (`logx`) and starts the Bubble Tea model (`model.New(meta)`). +sets up the error journal (`logx`) and starts the Bubble Tea model +(`model.New(meta).WithAppVersion(ver)` — the running binary's version, which drives the +self-check). The only CLI surface is `--version`/`-V` and `--help`/`-h` (handled in `main.go` before the TUI starts, so `keeptui` can be probed by version detectors — including itself); any other argument errors out instead of booting the TUI. +`main.go` keeps the model `p.Run()` returns for one purpose: `RestartRequested()` is how +`[U] restart` after a self-update reaches `restartSelf()` (`restart.go` — the shared +`restartHint` const, the only piece both platforms use; `restart_unix.go` — `syscall.Exec` +plus the pure `resolveSelfPath` core, none of which the Windows build calls; +`restart_windows.go` — the print-and-exit degradation). +Both ends of that wiring sit in named functions outside `runTUI` — `newRootModel(meta, ver)` +and `restartIfRequested(final, restart)` — because `runTUI` needs a terminal and cannot be +tested, and those two lines are the whole path from the model logic to the user. ## Package map @@ -45,7 +55,7 @@ graph TD | `internal/proc` | `DetachTTY` — run probes without a controlling terminal; `KillGroup` — process-group SIGKILL (plain `Kill` on Windows) | | `internal/ui` | Lip Gloss styles, `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`) | +| `internal/version` | Detect the installed version locally — `InstalledVersion(t) (ver, present)`; GitHub API with a 24-hour cache; semver comparison (`IsNewer`); keeptui's own release check (`SelfRepo`, `SelfLatest`) | `configdir`, `launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph: they know nothing about the TUI (`ui`, `updater` and `version` reach only into @@ -100,6 +110,11 @@ versa). On selection change `autoFetchCmdsForSelected()` fills in what's missing the pure predicates `needsInstalled`/`needsRemote`/`needsReadme` skip what is already cached. +Two commands in the `Init()` batch belong to no tool: `fetchRateCmd` (seeds the quota +gauge — warm-cache starts make no other request) and, on release builds only, +`selfCheckCmd` (keeptui's own latest release; see *Self-update and restart*). Both sit +at the front of the batch — the README seed has to stay its last element. + ### Probe sandbox A tracked tool may respond to `--help` by booting its own TUI — that must not shred @@ -170,6 +185,13 @@ Key invariants: - **`m.helpCache` is a `map[string][2]string` whose values are indexed by `m.helpMode`.** README content lives in a separate map (`m.readmeData`), so every index site is guarded by a README branch first — mode `2` would otherwise run off the end of the array. +- **One predicate decides who owns panel `[3]`.** An update log can claim the panel + either per tool (the buffered tool is the selected one) or regardless of the + selection (a self-update — keeptui is typically not tracked and the tracker may be + empty). Both cases are the single argument-less `showsUpdateLog()`, used by the inset + title, the render branch, the per-chunk repaint, the `setHelpContent` entry gate and + the help fetch, so they cannot disagree. Releasing a *completed* self-update's claim + is `dismissSelfLog()`. ## Updating a tool (`u`) @@ -193,6 +215,79 @@ is mandatory, `Wait` before the pipe is drained is forbidden by `os/exec`. lives in `[3] Update` (a ~500-line buffer); the 10-minute deadline ends with `proc.KillGroup` on the process group. +## Self-update and restart (`U` / `X`) + +keeptui watches its own releases and installs one through the same pipeline as `[u]`. +**The main case is a keeptui that is not tracked**, which is what shapes the design: no +step in this path may depend on `meta.yaml`, on a selection or on a card. + +`WithAppVersion(v)` injects the running binary's version (a builder, not a `New` +parameter — the zero value leaves the feature off, so the hundred-odd existing `New(` +call sites in tests were untouched). `selfCheckEnabled()` — non-empty, not `"dev"`, and not a +working-copy version (a Go pseudo-version tail or any `+dirty` build metadata, both of +which `go build .` stamps since Go 1.24) — is the one gate: a dev build makes no request +and shows no UI. `selfCheckCmd` calls +`version.SelfLatest()` (one release-only request per 24-hour window, with its own +`ReleaseCheckedAt` timestamp so it can never mark a repo card as fresh-but-blank) and +the handler compares the tag against `appVersion` — deliberately not against the +locally detected installed version, whose fallbacks can report a version that is not +the one this process is running. + +The banner is a five-state machine (`selfState`: none → offered → dismissed, and +updated → later) with **no "updating" member**: whether a self-update is in flight is +derived from the pipeline itself (`selfUpdating()`), so the two cannot drift. Every +site that asks "is this keeptui's own update?" asks one predicate, +`isSelfUpdate(name)` = the target name *and* the version gate — the name says which +kind of update it is, the gate says whether that kind exists on this build, and a +name-only test would switch the entire feature on for a dev build through `u` on a +tracked `keeptui` row. `U` acts +(detect, or restart once updated), `X` folds the banner into a compact cell next to the +quota gauge — a fold, not a cancel, since `U` stays reachable there for the rest of the +session, and the cell outranks both the gauge and the trailing hints so it survives the +80-column baseline. Nothing is written to disk. One update runs at a time in both +directions: `U` and `u` both refuse — with a status message, not silently — while any +update is running. `U` checks the banner state *before* that refusal, though: with no +banner up the key is unbound, and since `selfNone` is the only state a dev build ever +reaches, answering there would give a build with the feature off one audible piece of +it. + +Detection, the confirm dialog and the streaming log are the `[u]` machinery, and the way +the self case fits into it is a **name**, not a parallel path: the detection result +carries the target, the handler stores it as `updateTarget`, and everything downstream — +the confirm dialog, its status bar, the log's claim on panel `[3]`, the completion +handler — keys off that name instead of `selectedMeta()`, which an untracked keeptui (or +an empty tracker) cannot answer. So an update of keeptui is a self-update whichever key +started it, `u` on a tracked keeptui row included — on a build where the feature is +live; with it gated off that same keypress stays a plain tool update end to end (no +panel-owning log, no banner, no restart to offer). Two gates still differ: a landed +detection must match the selection only on the tool path (`acceptsUpdateDetect`; both +paths refuse while an update runs or an input mode owns the keyboard, since the answer +can arrive seconds later and a dialog opening under an editor would steal its +keystrokes), and the completion handler settles the self case ahead of the `toolByName` +lookup whose early return would otherwise drop the message for an untracked keeptui — +leaving the update silently finished and `[U] restart` unreachable. Failure writes no +banner state at all: the banner reappears by itself once the in-flight flag clears, so +`U` is the retry, a fold stays folded, and an earlier restart offer survives — while +forcing "offered" there could only walk one of those back, or announce an update with +no version behind it. + +Restart itself is a flag, not an exec: `U` sets `restartRequested` and returns +`tea.Quit`, and `main` re-execs only after `p.Run()` returned — Bubble Tea has restored +the terminal by then, whereas exec'ing from inside `Update` would hand the new process +an alt screen it never opened. `syscall.Exec` with the same argv and environment keeps +the pid, the terminal tab and the tmux pane. Which binary to exec is decided by the +pure `resolveSelfPath`, mirroring how a shell resolves the typed command: an argv0 +carrying a path separator is used as-is when it exists, a bare argv0 goes through +`LookPath` **first** (accepting the hit only when it names the same program as +`os.Executable()`, since argv0 comes from the parent and a wrapper could point it at +anything), and `os.Executable()` is only the fallback. That order matters on +Linux, where `os.Executable()` reads `/proc/self/exe` symlink-resolved and can still +name the live *old* binary after a keg-style upgrade — exec'ing it would loop restart +into the same banner. On Windows there is no image-replacing exec, so `restartSelf` +degrades to printing `keeptui updated — run keeptui again` and exiting; the same hint +covers a unix resolution or exec failure (on stdout, exit code 0 — the update did +succeed). + ## Running a tool (`enter`) `enter` in `[1] Tools` opens a one-line prompt (prefilled with the last command @@ -223,7 +318,9 @@ Without a token — 60 requests/hour per IP, with a token — 5000. A tool with costs 3 requests at startup, plus one lazy request for the README of the tool opened in panel `[3]` (`GET /repos/{owner}/{repo}/readme` with `Accept: application/vnd.github.raw+json` — `doGH` only defaults `Accept` when the -caller left it empty). Token: `GITHUB_TOKEN` from the environment always wins over the +caller left it empty). On a release build the self-check adds one release-only request +per 24-hour window; a tracked keeptui shares that cache entry, so a fresh full pass +makes the check free. Token: `GITHUB_TOKEN` from the environment always wins over the `~/.config/keeptui/token` file (`0600`); a token entered in the TUI is validated with a `/rate_limit` request before being written to disk. @@ -240,10 +337,19 @@ caller left it empty). Token: `GITHUB_TOKEN` from the environment always wins ov back; parallel startup goroutines never clobber each other's repositories. `mutate` always mutates a copy of the existing entry instead of building a `CacheEntry{…}` literal — a literal silently drops the fields that writer does not know about. The - README has its own freshness timestamp (`ReadmeCheckedAt`), separate from the - card's `CheckedAt`, so the two poison-guards stay independent. Force refresh (`r`) - skips only the freshness check, keeping the merge and the guard against poisoning - the cache with an empty response. + README and the self-check each have their own freshness timestamp + (`ReadmeCheckedAt`, `ReleaseCheckedAt`), separate from the card's `CheckedAt`, so the + three poison-guards stay independent: the card's gate has no content check, and a + narrower pass stamping the shared timestamp would serve a blank card for the whole + TTL. No pass may destroy another's content either: a 404 on `/releases/latest` is + remembered in a flag (`ReleaseMissing`, maintained by all three release writers — + including the changelog fetch, which is the only one still asking once the card is + fresh — through the one shared `applyReleaseOutcome`, so no writer can keep half of + the contract) and never by clearing the release tuple the card shows, so the self-check's + banner goes quiet while a tracked keeptui keeps its `latest:` line, its date and its + changelog. + Force refresh (`r`) skips only the freshness check, keeping the merge and the + guard against poisoning the cache with an empty response. ## Storage @@ -252,7 +358,7 @@ The base dir comes from `configdir.Base()`: `~/.config/keeptui` on macOS and Lin | Data | Path | |---|---| | Tracker metadata | `~/.config/keeptui/meta.yaml` | -| Version + README cache (24h TTL each, separate timestamps) | `~/.config/keeptui/cache.json` | +| Version, README and self-check cache (24h TTL each, separate timestamps) | `~/.config/keeptui/cache.json` | | GitHub token (`0600`) | `~/.config/keeptui/token` | | Session error log | `~/.config/keeptui/logs/keeptui-.log` | | Pre-migration tracker copy (written once, when a load dropped tags) | `~/.config/keeptui/meta.yaml.bak` | @@ -291,8 +397,11 @@ Per-test setup still uses the internal seams: `testConfigDir`, `testCacheDir`, `testTokenDir`, `testAPIBase`, `testBrewPrefix` (one copy in `version`, a second in `updater` — each private to its package), `updater`'s `testHomeDir`, and `model`'s `testReadmeStyle` (forces the glamour construction failure so the -plain-text fallback is covered). The races are real (mutexes in `version`, `logx`), -so tests always run with `-race`: +plain-text fallback is covered). `testAPIBase` is private to `version`, so a `model` +test cannot redirect a fetch at an httptest server — a network command is executed +there only when the cache can answer it (`seedSelfReleaseCache` for the self-check); +otherwise `Init` batches are asserted by length, never run. The races are real (mutexes +in `version`, `logx`), so tests always run with `-race`: ```bash go test -race ./... diff --git a/CLAUDE.md b/CLAUDE.md index 4430701..b3db973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ go vet ./... # static analysis golangci-lint run # lint (config in .golangci.yml; requires golangci-lint v2) ``` -CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint on every push and PR to `main`. Release is triggered by pushing a `v*` tag; GitHub Actions builds for darwin/linux/windows via `.github/workflows/release.yml`, injecting the tag with `-ldflags "-X main.version="`. `main.version` (default `"dev"`) exists so that ldflag actually takes effect — it seeds the session-log header and the `--version` output via `buildVersion()`, which falls back to the module version from `debug.ReadBuildInfo()` (set by `go install …@` and VCS-stamped `go build`) when the ldflag wasn't injected; only a build with no usable buildinfo shows `dev`. +CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint on every push and PR to `main`, plus a **cross-compile step** (`GOOS=windows go build`+`vet`, `GOOS=darwin go build`): the runner is linux-only, so without it the build-tagged files for the other platforms (`restart_windows.go`, `restart_unix.go`, `internal/proc/detach_windows.go`) would be compiled by no automated check at all. Release is triggered by pushing a `v*` tag; GitHub Actions builds for darwin/linux/windows via `.github/workflows/release.yml`, injecting the tag with `-ldflags "-X main.version="`. `main.version` (default `"dev"`) exists so that ldflag actually takes effect — it seeds the session-log header, the `--version` output and — via `WithAppVersion` — the self-check gate, whose `dev` default is what keeps that feature off in a working copy. `buildVersion()` falls back to the module version from `debug.ReadBuildInfo()` (set by `go install …@` and VCS-stamped `go build`) when the ldflag wasn't injected; only a build with no usable buildinfo shows `dev`. Note what that fallback means since Go 1.24: a plain `go build .` from a checkout reports a **pseudo-version** (`v0.0.0--`, `+dirty` with uncommitted changes), not `dev` — which is why the self-check gate rejects that shape too (see **Self-update**). ## Architecture @@ -21,7 +21,9 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint ### Entry point -`main.go` is a thin launcher: it loads tracker metadata via `loader.LoadMeta()` and starts the Bubble Tea TUI with `model.New(meta)`. Before that, `handleCLI` answers `--version`/`-V`/`-v`/`version` (prints `keeptui ` — dotted-numeric on release/`go install` builds, so `version.InstalledVersion`'s regex parses it and a keeptui tracked inside keeptui shows as installed) and `--help`/`-h`/`help` (static usage text); **any other argument exits 2 with usage on stderr** — falling through to the TUI is what used to make a probed keeptui boot Bubble Tea on a detached TTY, fail with `could not open a new TTY`, and litter `logs/` with junk session files. There are no other subcommands or flags. +`main.go` is a thin launcher: it loads tracker metadata via `loader.LoadMeta()` and starts the Bubble Tea TUI with `model.New(meta).WithAppVersion(ver)` — the running binary's own version, which is what the self-check compares against keeptui's latest release and the gate that switches that whole feature off on a dev build (see **Self-update** below). Before that, `handleCLI` answers `--version`/`-V`/`-v`/`version` (prints `keeptui ` — dotted-numeric on release/`go install` builds, so `version.InstalledVersion`'s regex parses it and a keeptui tracked inside keeptui shows as installed) and `--help`/`-h`/`help` (static usage text); **any other argument exits 2 with usage on stderr** — falling through to the TUI is what used to make a probed keeptui boot Bubble Tea on a detached TTY, fail with `could not open a new TTY`, and litter `logs/` with junk session files. There are no other subcommands or flags. + +`runTUI` keeps the model `p.Run()` returns (it used to discard it) for one reason: `RestartRequested()` is how `[U] restart` after a self-update reaches `restartSelf()`. `runTUI` itself opens a Bubble Tea program on a TTY and cannot be tested, so — the same pure-core-plus-thin-wrapper split as `shellCommand`/`planFor`/`resolveSelfPath` — the two lines that carry the whole self-update feature from the model to the user live outside it: **`newRootModel(meta, ver)`** (the `model.New(meta).WithAppVersion(ver)` wiring, without which every shipped binary would silently have no self-check, no banner and no restart offer) and **`restartIfRequested(final, restart)`** (the post-`p.Run()` decision, with the restart function injected exactly like `restartSelfWith`'s `execve`). Both had zero coverage until a review mutation showed the full suite staying green with each of them removed; `TestNewRootModelInjectsAppVersion` (counts `Init`'s batch — a release version queues one command more than a dev one) and `TestRestartIfRequested` are what kill those mutations now, and deleting the `restartIfRequested` call from `runTUI` is a compile error because `final` would go unused. `restartIfRequested` asserts on a local **`restarter` interface**, not on `model.Model`: the flag is only reachable through unexported state, so a stand-in is the only way to exercise the true branch — the package-level `var _ restarter = model.Model{}` is what keeps the real model bound to it. Still uncovered in `main.go` and known to be so: `buildVersion()` (its pure `resolveVersion` core is tested, the `debug.ReadBuildInfo` wrapper is not), the `logx.SetHeader` format strings, `migrateConfigDir` and `main`'s own `handleCLI` dispatch. The root package carries three more files for the restart, and each one's build tag matches where its code is actually reachable: `restart.go` — **only** the shared `restartHint` const, the one thing both platforms use; `restart_unix.go` (`//go:build !windows`) — `restartSelf`/`restartSelfWith` (`syscall.Exec` over `os.Args`/`os.Environ()`) plus the pure `resolveSelfPath` core and its `selfPath()`/`fileExists` wrappers, all of which the Windows build never calls; `restart_windows.go` — the honest degradation (print the hint, exit). Keeping the path core out of the untagged file is why `unused` no longer depends on a test to see it referenced. ### Package overview @@ -35,7 +37,7 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint | `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` | Lip Gloss styles and `PlaceOverlay` helper | | `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` (brew → go → cargo → pipx → npm chain; `update_cmd` override always wins; on a `LookPath` miss, a brew-by-name fallback before giving up — see the `[u]` section). Bottom of the import graph like `version`: no TUI knowledge, depends only on `loader` for `Tool`. Pure `detectFromPath`/`brewNamePlanAt` cores + OS-facing `Detect`/`brewNamePlan` wrappers; `testHomeDir` and `testBrewPrefix` seams (the latter a deliberate duplicate of `version`'s — two bottom leaves that may not import each other) | -| `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`) | +| `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`); keeptui's own self-check (`selfcheck.go`: `SelfRepo`, `SelfLatest`) | The `model` package is split by responsibility (one package, several files): @@ -43,7 +45,7 @@ The `model` package is split by responsibility (one package, several files): |---|---| | `model.go` | `Model` struct, msg types, `New`, `Init`, `Update` dispatch, selection/filter helpers | | `mode.go` | `inputMode` enum + per-mode key handlers (`updateNoteEdit`, `updateTagsEdit`, `updateTrackInput`, `updateUntrackConfirm`, `updateRenameInput`, `updateRunInput`, `updateAPIStatus`, `updateConfirmUpdate`, `updateHotkeys`) and the pure `trackTool`/`renameTool` | -| `commands.go` | Every `tea.Cmd` constructor (`fetchInstalledCmd`, `remoteCmd`, `fetchRateCmd`, `changelogCmd`, `fetchHelpCmd`, `readmeCmd`/`fetchReadmeCmd`/`refreshReadmeCmd`, `validateTokenCmd`, `detectUpdateCmd`, `startUpdateCmd`, `waitForChunkCmd`, `startLaunchCmd`, `execToolCmd` + the pure per-GOOS `shellCommand`) + fetch predicates (`needsInstalled`, `needsRemote`, `needsReadme`, `refreshSelectedCmd`, `autoFetchCmdsForSelected`) | +| `commands.go` | Every `tea.Cmd` constructor (`fetchInstalledCmd`, `remoteCmd`, `fetchRateCmd`, `selfCheckCmd`, `changelogCmd`, `fetchHelpCmd`, `readmeCmd`/`fetchReadmeCmd`/`refreshReadmeCmd`, `validateTokenCmd`, `detectUpdateCmd(t, self)` (one command for `[u]` and `[U]` — the flag only tags the message), `startUpdateCmd`, `waitForChunkCmd`, `startLaunchCmd`, `execToolCmd` + the pure per-GOOS `shellCommand`) + fetch predicates (`needsInstalled`, `needsRemote`, `needsReadme`, `refreshSelectedCmd`, `autoFetchCmdsForSelected`) | | `render.go` | `View`, panel/card/status-bar/gauge/overlay renderers, scrollbar, mouse handling | | `readme.go` | `renderReadme` (glamour) + the single-entry `readmeRenderCache`; `readmeStyleName`/`testReadmeStyle` seam | | `textutil.go` | Pure text/format helpers (`wrapText`/`wrapLine`, `stripANSI`, `colorizeHelp`, `parseHelpEntries`, `findMatches`, `formatStars`, `renderLangBar`, …) | @@ -64,7 +66,7 @@ The `model` package is split by responsibility (one package, several files): **Tool probes are sandboxed on two layers** (a tracked tool that ignores its args and boots its own TUI — e.g. a ratatui app answering `--help` with the app itself — must not shred keys' screen): (1) every probe subprocess (`fetchHelpCmd`, `version.InstalledVersion`, `man`) runs through `proc.DetachTTY`, i.e. in its own session with no controlling terminal, so the child's attempt to open `/dev/tty` fails (ENXIO) instead of toggling raw mode/alt-screen on the live terminal; (2) captured output is sanitized before it can reach a viewport — `ui.StripANSI` delegates to `x/ansi.Strip` (full escape grammar: private-mode CSI like `ESC[?1049l`, OSC, DCS — not just SGR) and `cleanTerminalOutput` additionally drops stray control characters (keeps `\n`/`\t`), because anything left in viewport content is re-emitted verbatim by the renderer and flips the real terminal's state. On top of that, `fetchHelpCmd` discards a capture whose RAW bytes carry the alt-screen signature (`isTUITakeover`, `ESC[?1049`): that's a TUI boot plus crash trace, not help text, so the panel falls through to the friendly `No --help output for …` message. `version.InstalledVersion` applies the same guard to its own `--version`/`-V` captures — with its own copy of `isTUITakeover`, since `version` sits below `model` in the import graph — and there it also stops the probe loop and drops the accumulated failure reasons: parsing that capture would mis-read a dependency's version out of the panic trace (`ratatui-0.30.2`), and logging it would re-create a session log on every startup for a tool that is merely a TUI app. -`Init()` fires `fetchInstalledCmd` + (conditionally) `fetchRemoteCmd` for every tool, plus changelog/README for the selected one (the README is what panel `[3]` shows on the first screen, so without that seed the default mode would sit on a placeholder until the selection moved). The `--help` seed is gated on `helpMode == helpModeHelp` — the probe spawns a subprocess, and in the default readme mode its capture would never be rendered (same reasoning as the readme case in `autoFetchCmdsForSelected`). `autoFetchCmdsForSelected()` runs after track/untrack/rename and selection changes (every selection move — `j`/`k`, arrows, pgup/pgdown, search commit/rollback, mouse click — funnels through the shared `selectMeta(idx)` helper in `model.go`, which refreshes the tools list, brief card and help viewport and fires the auto-fetch); it re-fetches the same sources for the selected tool, guarded by the pure predicates `needsInstalled(t)` / `needsRemote(t)` (skip if already cached; `needsRemote` also requires `t.GitHub != ""` and a missing `Latest` or card). If a tool is added or renamed mid-session, this path populates its card without a restart. Rename also deletes the stale old-name entries from `m.repoCards` / `m.versions` / `m.repoStatus` / `m.changelogData` / `m.helpCache` / `m.readmeData` so the tool re-fetches under its new name. +`Init()` fires `fetchInstalledCmd` + (conditionally) `fetchRemoteCmd` for every tool, plus changelog/README for the selected one (the README is what panel `[3]` shows on the first screen, so without that seed the default mode would sit on a placeholder until the selection moved). Two commands in the batch are **not** per tool: `fetchRateCmd` (the gauge seed) and — when `selfCheckEnabled()` — `selfCheckCmd` (keeptui's own release, see **Self-update**), appended right after it and deliberately **not last**, because `TestInitFetchesReadmeForSelected` executes the batch's final element and it must stay the README seed. The `--help` seed is gated on `helpMode == helpModeHelp` — the probe spawns a subprocess, and in the default readme mode its capture would never be rendered (same reasoning as the readme case in `autoFetchCmdsForSelected`). `autoFetchCmdsForSelected()` runs after track/untrack/rename and selection changes (every selection move — `j`/`k`, arrows, pgup/pgdown, search commit/rollback, mouse click — funnels through the shared `selectMeta(idx)` helper in `model.go`, which refreshes the tools list, brief card and help viewport and fires the auto-fetch); it re-fetches the same sources for the selected tool, guarded by the pure predicates `needsInstalled(t)` / `needsRemote(t)` (skip if already cached; `needsRemote` also requires `t.GitHub != ""` and a missing `Latest` or card). If a tool is added or renamed mid-session, this path populates its card without a restart. Rename also deletes the stale old-name entries from `m.repoCards` / `m.versions` / `m.repoStatus` / `m.changelogData` / `m.helpCache` / `m.readmeData` so the tool re-fetches under its new name. **Version comparison** (`version.IsNewer`) is semver via `golang.org/x/mod/semver` with a canonicalization layer (`canonSemver`) that also accepts what strict semver rejects but real tools emit: zero-padded CalVer segments (`2024.01.15`) and 4-segment versions (`1.2.3.4`, truncated to three). Pre-releases order below their release; either side failing to canonicalize means "not newer". @@ -74,7 +76,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 three-column overlay listing every normal-mode binding one-per-line, each group annotated with the panel/mode it belongs to (`renderHotkeys()` in render.go). Each group header is preceded by a blank line (except the first in its column) and sits **directly on its own rows** — the blank line that used to follow the header was reclaimed when the `[3]` group grew the `r` readme row: at 7 rows that group pairs with `Scrolling` (or anything else) past the 20-row budget in every column partition. **The overlay is now exactly at the budget** (20 rows framed, 75 of 76 columns; column 1 = `Global` 5 rows + `[1] Tools` 8): a new binding row cannot simply be appended — it needs a row removed, a description shortened, or the groups repartitioned across columns, and `TestRenderHotkeysSizeBudget` is what says which. The title `Keyboard shortcuts` keeps a blank line below it, and inside a column every key cell is padded to the column's widest key so descriptions align and keys never drift. It reuses the `[L]` overlay styling (`ui.PlaceOverlay` dimmed background, `OverlayBorder`, `SectionLabelStyle` headers, `keyHint` keys, `InfoStyle` text) and closes with `esc`, `q`, or a second `?`; 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. The three normal focus bars carry a `[?] keys` hint. +**Hotkeys overlay (`[?]`)**: `?` in `modeNormal` (any focus) opens `modeHotkeys` — a static, centered three-column overlay listing every normal-mode binding one-per-line, each group annotated with the panel/mode it belongs to (`renderHotkeys()` in render.go). Each group header is preceded by a blank line (except the first in its column) and sits **directly on its own rows** — the blank line that used to follow the header was reclaimed when the `[3]` group grew the `r` readme row: at 7 rows that group pairs with `Scrolling` (or anything else) past the 20-row budget in every column partition. **The overlay is exactly at its height budget** (20 rows framed, 75 of 76 columns; column 1 = `Global` 5 rows + `[1] Tools` 8 is the tallest at 16 content rows): a new binding row cannot simply be appended to column 1 or 3 — it needs a row removed, a description shortened, or the groups repartitioned across columns, and `TestRenderHotkeysSizeBudget` is what says which. Column 2 (`[2] Brief` 9 rows + the `Self` group's `U`/`X`, 14 content rows) is where the self-update keys landed precisely because it was the shortest, and it still has ~2 rows of slack before it ties column 1. Descriptions there must stay ≤ 12 cells (`cycle status`, the column's widest; `self-update` is 11) or the column — and with it the overlay — grows past 76 columns. The `Self` group is the one **state-dependent** part of the overlay: 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. `TestRenderHotkeysSizeBudget` checks the budget in **all five** states. The title `Keyboard shortcuts` keeps a blank line below it, and inside a column every key cell is padded to the column's widest key so descriptions align and keys never drift. It reuses the `[L]` overlay styling (`ui.PlaceOverlay` dimmed background, `OverlayBorder`, `SectionLabelStyle` headers, `keyHint` keys, `InfoStyle` text) and closes with `esc`, `q`, or a second `?`; 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. The three normal focus bars carry a `[?] keys` hint. **Tool-list rows** (`renderLeftContent`) follow "one glyph, one meaning" in the width-1 marker column: the selected row always carries `⏺` (U+23FA) — peach (`SelectionBarStyle`) with the name in bold peach (`SelectedNameStyle`) while the list is focused, dim (`SelectionBarDimStyle`) otherwise — so the selection never disappears when focus moves to brief/help. Every non-selected row gets a plain space in the marker column — there is **no** status edge (tool status lives in the brief card only, editable via `s`). A tool whose installed version is older than the latest release gets a ` ↑` (U+2191, `UpdateAvailableStyle`) after the name. Both `⏺` 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 Ambiguous class. `TestMarkerGlyphWidth` pins both conditions; the row-width math is stable in the default condition every non-East-Asian terminal uses. In the tag view the list additionally carries non-selectable **divider header rows** with the **label centered** (`────… ────…` / `────… untagged ────…` — the remaining rule is split evenly on both sides of the spaced label, an odd extra dash going right): the label in muted `TagHeaderStyle` (`ColorDim`) and the rule lines in `TagRuleStyle` (`ColorBorder`, the panel-frame gray), **no hashtag** — group boundaries read geometrically so the tool names stay the only color accent; deliberately **not** `SectionLabelStyle`, whose `ColorCategory` competes with the peach name accent. (`─` U+2500 is East-Asian Ambiguous, the same accepted class as `⏺`/`↑` and the panel borders — 1 cell in the default condition.) One header **must** be exactly one screen line — every consumer of the line maps assumes it — so `tagHeaderLine` flattens the label (`flattenLine`: a tag from a hand-edited `meta.yaml` can carry a newline, which would silently emit a second row), cuts it with `truncateToWidth` (which measures in **display cells via `lipgloss.Width`**, not runes: a rune count lets a CJK tag render past the panel edge, where the viewport hard-cuts it mid-glyph), and builds the whole `────… label ────…` line to **exactly** the list width (`max(toolsW-1,1)` cells) so the viewport never wraps or truncates it; a panel too narrow to frame (`< 4` cells) degrades to the bare truncated label. (It cuts on rune boundaries, so a multi-rune grapheme cluster can still split — cosmetic, and doing better needs a segmenter.) @@ -82,7 +84,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu **Tag grouping (`space` in `focusTools`)**: the second list view, off by default (`m.groupByTag`). `m.grouped()` — `groupByTag && searchQuery() == "" && hasTaggedTool()` — is the single "tag view is on" predicate, shared by the ordering and the header rows so they can never disagree; an active `/` search suppresses it, so search behaves exactly as before, and so does a tracker with **no** tagged tool (grouping it would draw one `untagged` divider over the unchanged list — a keypress that reads as broken). `toggleGroupByTag` refuses to turn the view *on* in that state with a `no tags to group by` statusMsg, but never refuses to turn it **off** — otherwise removing the last tag would strand the user in the tag view; a successful toggle reports `grouped by tag` / `flat list`, the only feedback a view switch has. All three exits route through `setStatus`, so `toggleGroupByTag` returns a `tea.Cmd` (the expiry tick) that the `space` case returns — the message auto-clears after `statusMsgTTL` (see the status-message lifecycle below); a view toggle still fetches nothing. Ordering lives in the same single projection point: `searchMatches()` returns early through `groupMatchesByTag(out)`, which puts tools sharing a tag together (groups in first-appearance order, `meta.yaml` order inside a group, untagged last). The grouping identity is **`tagKey(mt)` — the case-folded tag**, because `matchingTag` already compares case-insensitively and two spellings the search treats as one tag must not split into two sections; the header shows the group's first spelling. Grouping and the update partition are **exclusive** — the partition would break the sections' contiguity — so in the tag view an updatable tool stays in its group and only keeps its ` ↑` marker. **`metaSelected` stays a tool index into `filteredMeta()`**, never a screen row: that is what keeps navigation (`j/k/g/G/PgUp/PgDn/ctrl+d/u`), `selectedMeta()`, `indexOfMeta` and every index-writing site untouched by the feature. The header rows exist only in the two translation points, via maps built by `buildToolRows()` alongside the content — `toolLine[i]` (tool index → screen line) and `lineTool[l]` (screen line → tool index, `-1` on a header), both the identity when grouping is off: `syncToolsViewport` scrolls to `selectedLine()` (**both** branches in screen-line units — mixing units breaks scrolling the moment a header sits above the selection; the upward clamp additionally pulls in a header sitting directly above the selection, or the first group would render as if it had none, with no keyboard way to bring it back), `handleMouse` maps a click row through `toolAtLine()` (a header click selects nothing but still focuses the panel), and the **page/half-page keys step screen lines, not tools** — their step is a viewport-row count, so `PgUp/PgDn/ctrl+f/ctrl+b/ctrl+d/ctrl+u` go through `toolNearLine(selectedLine() ± step, dir)`, which lands on the tool at that line or the next one in the direction of travel. Counting rows as tools would jump past the rows the headers occupied. Both helpers fall back to the identity when the maps are empty, so a hand-built model that never painted the list behaves as it did before. **Every repaint of the list content must go through `setToolsContent()`** — it writes the maps for the content it just rendered, so a bare `SetContent(renderLeftContent())` elsewhere would leave them describing the previous list (the `WindowSizeMsg` handler was exactly that and now routes through it; `TestWindowSizeRebuildsLineMaps` guards it). The toggle itself (`toggleGroupByTag`) reorders `filteredMeta()`, so it uses the same capture-name-then-`indexOfMeta` remap as the async handlers — deliberately not via `selectMeta`, since a pure view toggle must fire no auto-fetch. -**Input modes**: all input/modal state lives in one `m.mode inputMode` field (`mode.go`) — `modeNormal` (base), `modeSearch`, `modeHelpSearch`, `modeEditNote`, `modeEditTags`, `modeTrack`, `modeConfirmUntrack`, `modeRename`, `modeRunInput`, `modeConfirmUpdate`, `modeAPIStatus`, `modeTokenInput`, `modeHotkeys`. Exactly one mode is active at a time; the `tea.KeyMsg` branch in `Update()` dispatches on `switch m.mode`, so a non-normal mode's handler owns the input and other modes' opening keys cannot fire (the old per-flag guard bugs are structurally impossible). `modeTokenInput` is a sub-state of the API-status overlay: entered from `modeAPIStatus` via `[e]`, esc returns to `modeAPIStatus`, and `apiOverlayVisible()` reports "overlay on screen" for both. `overlayVisible()` is the broader predicate — `apiOverlayVisible() || modeHotkeys` — used by `View()` and the mouse gate to mean "any modal on screen". `refreshingFor`, `helpMode` and `focus` are deliberately *not* input modes and stay separate fields. +**Input modes**: all input/modal state lives in one `m.mode inputMode` field (`mode.go`) — `modeNormal` (base), `modeSearch`, `modeHelpSearch`, `modeEditNote`, `modeEditTags`, `modeTrack`, `modeConfirmUntrack`, `modeRename`, `modeRunInput`, `modeConfirmUpdate`, `modeAPIStatus`, `modeTokenInput`, `modeHotkeys`. Exactly one mode is active at a time; the `tea.KeyMsg` branch in `Update()` dispatches on `switch m.mode`, so a non-normal mode's handler owns the input and other modes' opening keys cannot fire (the old per-flag guard bugs are structurally impossible). `modeTokenInput` is a sub-state of the API-status overlay: entered from `modeAPIStatus` via `[e]`, esc returns to `modeAPIStatus`, and `apiOverlayVisible()` reports "overlay on screen" for both. `overlayVisible()` is the broader predicate — `apiOverlayVisible() || modeHotkeys` — used by `View()` and the mouse gate to mean "any modal on screen". `refreshingFor`, `helpMode`, `focus` and `selfState` (the self-update banner is non-modal by design — the whole app keeps working under it) are deliberately *not* input modes and stay separate fields. - **Tool-list search (`/` in `focusTools`)** is a commit/rollback transaction over `modeSearch`. `case "/"` captures `m.searchPrevName` (the selected tool's name; empty when the list is empty) before entering the mode, and `filteredMeta()` narrows the list live as the query changes. The predicate is `searchMatches()` (`model.go`): a tool matches when its **name OR its tag** contains the lowercased query (`matchingTag` still iterates the slice — it predates the one-tag invariant and stays correct under it), returning `[]searchMatch{meta, byTagOnly, tag}` so the renderer knows which rows matched only by tag; `filteredMeta()` is a thin projection over it, so all other callers (count, selection, cursor remap) keep seeing plain metas. While searching, the matched name substring renders peach-bold via `highlightNameMatch` (render.go — distinct from `highlightMatch` in textutil.go, which belongs to the help search), tag-only rows show the earning tag as a dim `#` suffix when it fits the row budget without wrapping, and the search status bar shows an `N/M` counter (matches / total tracked) between the query and the hints (a keystroke that changes the query text resets `metaSelected` to 0 — first match highlighted, marker visible during search; pure cursor movement like `left`/`right` keeps a user-moved highlight; that reset repaints `[3]` through **`setHelpContent()`**, not a bare `SetContent(renderHelpContent())`: the readme branch serves `m.helpBase`, which nothing else re-renders, so the cheaper call would leave the *previous* tool's README on screen under the new tool's name — no fetch is fired there, one per keystroke would spend the quota on rows merely typed past). Inside the mode: `↑`/`↓` move the highlight through the filtered list via `selectMeta` (modular wrap, full `j`/`k` parity; **never** forwarded to the textinput, so the query text is untouched — with zero matches they are consumed as no-ops); `enter` commits — exits to `modeNormal`, clears the query, remaps the cursor onto the unfiltered (but still update-grouped) list by name via `indexOfMeta(mt.Name)` — the search filter is gone once the mode is normal, but the update grouping is not, so `indexOfMeta` resolves the **displayed** index — and moves focus to `focusBrief` (no matches → no-op, search stays open); `esc` rolls back — unfiltered list with the cursor restored via `indexOfMeta(m.searchPrevName)` (fallback 0 when that tool was untracked mid-search). Both exits clear `searchPrevName` and go through `selectMeta`, so the help panel is re-synced too (an arrow move may have loaded another tool's help mid-search). `indexOfMeta(name)` lives next to `filteredMeta()` in `model.go`; the status bar echoes the live query plus the `N/M` counter and `[enter] open [↑/↓] move [esc] cancel` hints. - **Central panel actions (`focusBrief`)** operate on the data the card already shows: `o` opens the repo in the browser, `c` opens the changelog/releases page, `r` force-refreshes the tool's data, `s` cycles the status (`loader.NextStatus`: `active → trying → inactive`, unknown values fall back to active), `e` edits the note, `t` edits the tool's single tag. `o`/`c` go through `openURLCmd` (resolved per-`GOOS` by `browserCommand`); a tool with no `GitHub` sets `m.statusMsg` instead of launching. `s`/`e`/`t` mutate `m.meta` via `loader.UpsertMeta`, persist with `loader.SaveMeta`, then refresh the card with `m.briefViewport.SetContent(m.renderCard())`. The tags editor commits through `parseTag` (mode.go): the input is **one** tag — everything past the first comma is dropped, so typing `cli, foo` and loading a legacy `[cli, foo]` list both land on `cli` and the editor can never disagree with `LoadMeta`'s `Tags[:1]` migration about a tag's shape. Spaces inside a tag are kept (`dev tools`); empty input clears it to `nil`, which `omitempty` drops from `meta.yaml`. Everything downstream reads the one tag through `tagOf(mt)` rather than joining the slice. @@ -90,16 +92,28 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Card versions**: `renderCard`'s `[info]` section shows `installed:` from `m.versions[name].Installed` — all four states (`installed: \uf412 `, `installed: detecting…`, `installed: ✓ no version`, `installed: ✕ not installed`) in the section's muted `InfoStyle`. The two version-less states split what `InstalledKnown` alone used to collapse, on `InstalledPresent`: a tool that is installed but won't name its version (a ratatui app that ignores `--version`) is a working install and reads `✓` (U+2713) in `ui.OkStyle` — green, deliberately not `UpdateAvailableStyle`, whose orange means "act on this" — while a genuine absence keeps the `✕` (U+2715) in `DangerStyle`, the same red glyph/style pair the `[L]` overlay uses for exhaustion. The section's gate includes `installed != ""`, so a locally installed tool with no `GitHub` ref still renders it. When `hasUpdate(name)` (installed older than latest per `version.IsNewer`) the `latest:` value renders in `UpdateAvailableStyle` with a ` ↑` suffix — the same glyph the tools list appends to the row. The `latest:` line is gated on `card.Latest != ""` (a repo with no release shows no line) and appends the release date as a ` (YYYY-MM-DD)` suffix derived from `card.PublishedAt`. **Both version lines carry the `\uf412` glyph** (nf-oct-tag) before the version — its meaning is "a version", not specifically "a release tag", which is why it widened from `latest:` to `installed:`; the three states with no version to tag (`detecting…`, `✓ no version`, `✕ not installed`) stay bare. Written as a `\u` escape in source and prose alike: the raw glyph is invisible in most editors and diffs and gets silently lost. Versions live in the card only; the 30-column list never shows version strings. - **Panel titles**: all three panels inset a title into their top border (`┌─ [1] Tools ─…─┐`) via the shared `insetPanelTitle` (render.go) — an ANSI-safe splice over the already-rendered frame (`ui`'s `truncateVisible` is unexported; the helper remeasures with `stripANSI` and rebuilds the top line), colored like the border (focus-aware) and dropped whole when the panel is too narrow. The titles are `[1] Tools`, `[2] Brief` and `[3] Readme` / `[3] Help` / `[3] Man` (a `switch m.helpMode`, overridden by `[3] Update` while a live log shows) — they double as the documentation for the digit focus hotkeys, so the status bar carries no digit hints. All title characters are single-width and non-East-Asian-Ambiguous, keeping the border width math stable. - **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. `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 the same `case "r"` branches on focus three ways: rename in `focusTools`, refresh in `focusBrief`, README mode in `focusHelp`. -- **Update (`u` in `focusBrief`)**: installs a newer release from inside the TUI. `[u]` fires only in `focusBrief` (in `focusTools` `u` stays untrack — same `case "u"`, branched on focus); it requires `hasUpdate(name)` (else `statusMsg`) and is a no-op while `updatingFor != ""` (one update at a time, no queue). The key fires `detectUpdateCmd(t)` — detection spawns subprocesses (`go version -m`, `cargo install --list`) so it must never run inside `Update()`, same as every other probe. `updater.Detect` runs the brew → go → cargo → pipx → npm chain (order matters: brew before go, so a brew-installed Go binary with buildinfo isn't misrouted); a `update_cmd` in `meta.yaml` always wins and runs via `sh -c` (skips detection entirely). The chain starts from `exec.LookPath(t.Name)`, and a **miss there is not the end**: the tracked name can still be a brew formula whose binaries are named differently — `rust` ships `rustc`/`cargo`, so `LookPath("rust")` misses while `brew upgrade rust` is exactly right — so `brewNamePlan` checks for a `Cellar/`/`Caskroom/` directory before `ErrUnknownManager` is returned. The branch is self-validating (it fires only when such a keg actually exists) and shares `version.brewDirVersion`'s traversal guard: a name carrying `/` or `\` can't be a formula name and must not turn the `Join` into a traversal. The `updateDetectedMsg` handler drops a stale result (tool no longer selected / update already running), maps `ErrUnknownManager` to a `statusMsg` hint (`no known updater for — set update_cmd or [o] releases` — no dead-end dialog), and on success stores `m.updatePlan` and enters `modeConfirmUpdate`. The confirm status bar shows `update : [enter] run [esc] cancel`; `enter` sets `m.updatingFor`, resets the log, and fires `startUpdateCmd` (any other key cancels, not just `esc`). The `[u] update` hint is prepended to the `focusBrief` bar only when `hasUpdate(selected)`. +- **Update (`u` in `focusBrief`)**: installs a newer release from inside the TUI. `[u]` fires only in `focusBrief` (in `focusTools` `u` stays untrack — same `case "u"`, branched on focus); it requires `hasUpdate(name)` (else `statusMsg`) and reports the shared `updateBusyStatus` (`another update is running`) while `updatingFor != ""` instead of starting a second one (one update at a time, no queue; the same wording `[U]` uses, since the running update's only other sign is a card spinner invisible unless that tool is selected). The key fires `detectUpdateCmd(t, false)` — detection spawns subprocesses (`go version -m`, `cargo install --list`) so it must never run inside `Update()`, same as every other probe. `updater.Detect` runs the brew → go → cargo → pipx → npm chain (order matters: brew before go, so a brew-installed Go binary with buildinfo isn't misrouted); a `update_cmd` in `meta.yaml` always wins and runs via `sh -c` (skips detection entirely). The chain starts from `exec.LookPath(t.Name)`, and a **miss there is not the end**: the tracked name can still be a brew formula whose binaries are named differently — `rust` ships `rustc`/`cargo`, so `LookPath("rust")` misses while `brew upgrade rust` is exactly right — so `brewNamePlan` checks for a `Cellar/`/`Caskroom/` directory before `ErrUnknownManager` is returned. The branch is self-validating (it fires only when such a keg actually exists) and shares `version.brewDirVersion`'s traversal guard: a name carrying `/` or `\` can't be a formula name and must not turn the `Join` into a traversal. The `updateDetectedMsg` handler drops a stale result through the shared predicate **`acceptsUpdateDetect(msg)`**: both paths refuse while `updatingFor != ""` and while `m.mode != modeNormal` (detection spawns subprocesses and can answer seconds later — a confirm dialog opening under an editor, a search or an overlay steals the keystroke aimed at it, the mirror of `launchDoneMsg`'s mode gate), and beyond that a *tool* result must still match the selection while keeptui's own has no selection to match. It maps `ErrUnknownManager` to a `statusMsg` hint (`no known updater for — set update_cmd or [o] releases` — no dead-end dialog; the wording branches on **`isSelfUpdate(msg.tool)`**, not on `msg.self`, so the identical failure of the identical binary reads the same from `[u]` on a tracked `keeptui` row as from `[U]` — `msg.self` keeps the one meaning only it has, "no selection to match", inside `acceptsUpdateDetect`), and on success stores `m.updatePlan` plus **`m.updateTarget = msg.tool`** and enters `modeConfirmUpdate`. The target is resolved *there*, not when enter is pressed: a selection that moved while detection ran can no longer retarget the dialog, and keeptui's own update — which has no row, and no selection at all with an empty tracker — needs no second identity. The confirm status bar shows `update : [enter] run [esc] cancel`; `enter` sets `m.updatingFor`/`m.updateLogFor` to that target, resets the log, and fires `startUpdateCmd` (any other key cancels, not just `esc`, and clears `updateTarget` with the plan it named). The `[u] update` hint is prepended to the `focusBrief` bar only when `hasUpdate(selected)`. - **Streaming** (channel + re-subscribe idiom, no `*tea.Program`): `startUpdateCmd` runs the plan via `exec.Command` + `proc.DetachTTY` (10-min deadline; a sudo prompt fails fast instead of hanging — deliberate), with stdout+stderr merged into one pipe. **Reader ordering is load-bearing** (os/exec forbids `Wait` before pipe reads finish): the goroutine scans the pipe to EOF via `streamLines` → then `cmd.Wait()` → sends the exit error as a final `updateLine{done:true, err}` → then `close(ch)`. `waitForChunkCmd` does one receive → `updateChunkMsg`; a done item or closed channel → `updateDoneMsg`. The channel carries a typed `updateLine{text, replace, done, err}` (not `chan string`) so the `replace` flag and completion error ride the same channel — no second error channel threaded through every re-subscribe. Each segment is sanitized through `cleanTerminalOutput` (which already strips ANSI) at the boundary; `streamLines` splits on `\n` **and** `\r`, and a `\r` segment sets `replace` so brew/npm progress bars collapse to one updating line. `m.updateLog` is capped at ~500 lines (tail matters). On deadline, `proc.KillGroup` SIGKILLs the process group (negative pid — `DetachTTY`'s `Setsid` makes the child a session leader, so a plain kill would orphan `sh -c` grandchildren). - - **Live log in `[3]`**: `m.updateLog []string` is a single active-session buffer (not a map); `m.updateLogFor` names its tool. `renderHelpContent()` returns the log **ahead of** the `helpLoadingFor`/cache branches when `updateLogFor == selected`, and `autoFetchCmdsForSelected` skips the help fetch (and `helpLoadingFor` set) for that tool — otherwise re-selecting it paints `Loading...` or a late `helpOutputMsg` clobbers the live log. The panel title reads `[3] Update` while the log shows (same `insetPanelTitle` path as `[3] Help`/`[3] Man`), autoscrolls to bottom on each chunk, and the buffer persists after completion until the next update. Navigating away shows the other tool's normal help; back shows the live log. - - **Spinner + completion**: `m.updatingFor` twins `refreshingFor` — card title `updating `; the `spinner.TickMsg` gate is `refreshingFor != "" || updatingFor != ""` (or the spinner freezes after one frame). The `updateDoneMsg` handler clears `updatingFor`; success → `statusMsg "updated "` + `fetchInstalledCmd(t)` (the version merge extinguishes `↑` and the existing by-name cursor remap moves the tool out of the update group); failure → `statusMsg "update failed — see [3]"` + `logx.Errorf` (manager, exit code, last log lines — never the token). A tool untracked mid-update just clears `updatingFor` (no re-fetch, no crash). -- **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]` (only in `focusHelp` — `case "r"` branches on focus: `focusTools` rename, `focusBrief` refresh), `[h]` and `[m]` (both fire from `focusBrief || focusHelp`) switch it. All three go through the shared **`switchHelpMode(mode)`** (model.go), which sets the mode, dismisses a *completed* update log, 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 `[h]`/`[m]` also fire from `[2]` and move it while `[r]` is `focusHelp`-only. A **live** update log keeps `[3]` in every path (the log branch sits ahead of the readme branch in `renderHelpContent`, `setHelpContent` gates on `updateLogFor != mt.Name`, and the readme case in `autoFetchCmdsForSelected` sits after the `updateLogFor` 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) sanitizes with `cleanTerminalOutput`, then runs glamour with a **fixed** `WithStandardStyle("dark"|"light")` — 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 — plus `WithWordWrap(helpWrapWidth())` and `WithColorProfile(lipgloss.ColorProfile())` (glamour hardcodes TrueColor and would ignore `NO_COLOR`/dumb terms); a glamour failure falls back to the sanitized plain text, never an empty panel. The result is memoized in the single-entry `readmeRenderCache` keyed by `(name, raw, width, dark)` — `setHelpContent` runs on every selection move and width resize and must not re-parse a large README each time. `parseHelpEntries`/`colorizeHelp` are **not** run in readme mode (glamour output is already ANSI): `helpEntries` stays empty so `j`/`k` are plain scroll, and `/` (help search) is a no-op — guarded at the shared `focusBrief || focusHelp` entry point, so the `[1]` tool-list search is unaffected; the `focusHelp` status bar drops the `[/] search` hint in readme mode rather than advertising a dead key. Placeholders are tool-named (`readmeContent` in render.go): no GitHub ref → `No repo for . Press [h] for --help.`, in-flight → `Loading...`, `ErrNoReadme` → `No README in . Press [h] for --help.`, `ErrRateLimited` → `rate limited — press [L]`, and a generic fallback `No README for . Press [h] for --help.` for everything else — a network/5xx failure **and** the case where content exists but glamour rendered it to nothing (a badges/HTML-comment-only README). That last one is why the "real content" test is `data.content != "" && m.helpBase != ""`: returning an empty render as content paints a blank panel with no way out. + - **Live log in `[3]`**: `m.updateLog []string` is a single active-session buffer (not a map); `m.updateLogFor` names its tool. Every site that asks "does the log own panel `[3]`?" asks the **single predicate `showsUpdateLog()`** (see **Self-update**), never `updateLogFor` directly: `renderHelpContent()` returns the log **ahead of** the `helpLoadingFor`/cache branches, and `autoFetchCmdsForSelected` skips the help fetch (and `helpLoadingFor` set) — otherwise re-selecting the tool paints `Loading...` or a late `helpOutputMsg` clobbers the live log. The panel title reads `[3] Update` while the log shows (same `insetPanelTitle` path as `[3] Help`/`[3] Man`), autoscrolls to bottom on each chunk, and the buffer persists after completion until the next update. For a *tool* update the claim is per tool: navigating away shows the other tool's normal help; back shows the live log. + - **Spinner + completion**: `m.updatingFor` twins `refreshingFor` — card title `updating `; the `spinner.TickMsg` gate is `refreshingFor != "" || updatingFor != ""` (or the spinner freezes after one frame). The `updateDoneMsg` handler clears `updatingFor`; success → `statusMsg "updated "` + `fetchInstalledCmd(t)` (the version merge extinguishes `↑` and the existing by-name cursor remap moves the tool out of the update group); failure → `statusMsg "update failed — see [3]"` + **`recordUpdateFailure(msg)`**, the helper shared with the self path that seeds the log with the exit error when the command produced no output at all (otherwise `[3]` would still read `starting update…` while the status bar points there) and writes the `logx.Errorf` line (manager, exit code, last log lines — never the token). One definition, so the two paths cannot drift in log format. A tool untracked mid-update just clears `updatingFor` (no re-fetch, no crash). +- **Self-update (`U`/`X`)**: keeptui watches its own releases and installs one through the very same pipeline as `[u]`. **The feature's main case is a keeptui that is not tracked**, so nothing in this path may read `meta.yaml`, the selection, or a card — every guard below that normally leans on `selectedMeta()` has a self counterpart that does not. The tracked case still works and shares the cache entry. + - **Version gate**: `WithAppVersion(v) Model` (model.go) injects `main.buildVersion()`'s result — a **builder**, not a `New` parameter, because `New(` is called from a hundred-odd tests and the zero value leaves the feature off, so none of them had to change. `selfCheckEnabled()` is the single gate expression, and it rejects **three** shapes, not one: the empty string (a model built without the builder — every existing test), the `"dev"` ldflag default, and anything `isDevVersion` recognizes as a working copy — a **Go pseudo-version** tail (`[-.]<14-digit ts>-<12-hex hash>`, matching all three of the form's variants) or any `+` build metadata (`+dirty`). That last part is not hypothetical: since Go 1.24 this project's own documented dev commands (`go build .`, `go install .`) stamp the module version from VCS, so a tagless checkout reports `v0.0.0--` — which `canonSemver` happily accepts as a pre-release sorting *below* every real tag, so without the check a developer's build would show the banner and `[U]` would `go install …@latest` over it. Only `go run .` still says `dev`. The predicate stays a named function rather than an inline `Init` condition because the rule is three clauses deep and needs its own table test (`TestSelfCheckEnabled`); it has **two** callers — `Init` (the request) and `isSelfUpdate` (the pipeline's self/tool discriminator, see **Completion**) — and the renderer deliberately does **not** re-ask it, which is sound only because those two together are what keep `selfState` at `selfNone` on such a build (see **Rendering**). On any dev build there is no request and no UI at all — the request half pinned by `TestInitSelfCheckGatedOnVersion`, the UI half by `TestKeeptuiUpdateSelfHandlingGatedOnBuild`. + - **The check**: `selfCheckCmd()` (no parameters — the repo is a constant) calls `version.SelfLatest()` and emits `selfCheckMsg{latest, err}`; it logs **nothing at all**, exactly like `remoteCmd` — `version` owns the record end to end (`doGH` for a transport failure, `classifyStatus` for a bad status, both with detail this layer does not have), and a second line per failure would mean every offline launch writes two, inflating the "a log file means something went wrong" signal instead of sharpening it. "No release published" is not a failure at all: it arrives as an empty tag with a **nil** error. The comparison lives in the handler, not the command, because only the model knows `m.appVersion`: `err != nil || latest == ""` → stay `selfNone`; `version.IsNewer(m.appVersion, latest)` → `selfLatest` + `selfOffered`. Compared against **`appVersion`**, deliberately not `m.versions[selfToolName].Installed`, whose three fallbacks (`--version`, brew dir, `cargo install --list`) can report a version that is not the one this process is running. The handler writes **only from `selfNone`** (`msg.err != nil || msg.latest == "" || m.selfState != selfNone` → return): the check answers once per session, and any later or repeated message — in *either* direction, a newer tag included — must not walk back a state the user already acted on (`selfDismissed`, `selfUpdated`, `selfUpdatedLater`). `TestSelfCheckMsgKeepsActedOnState` drives both answers against all three. + - **State machine `selfState`** (`selfNone` / `selfOffered` / `selfDismissed` / `selfUpdated` / `selfUpdatedLater`) has **no "updating" member** — "a self-update is in flight" is derived by `selfUpdating()` = `isSelfUpdate(updatingFor)`, so the banner and the pipeline cannot disagree about it. **`isSelfUpdate(name)` = `name == selfToolName && selfCheckEnabled()`** is the one place the pipeline asks "is this keeptui's own update?": the name decides which *kind* of update it is (an update of keeptui is a self-update whichever key started it), the version gate decides whether that kind exists on this build at all. Both clauses are load-bearing — a name-only test let `[u]` on a tracked `keeptui` row switch the whole feature on where it is documented as off (`TestSelfUpdatingPredicate`, `TestKeeptuiUpdateSelfHandlingGatedOnBuild`). `[X]` folds `selfOffered → selfDismissed` and `selfUpdated → selfUpdatedLater`; it is session-only, nothing is written to disk, and `[U]` stays reachable in the folded cell — the dismiss is a fold, not a cancel. **Five sites switch on `selfState`** (`selfUpdateKey`, `[X]`, `selfBannerCells`, `selfCompactCell`, the `[?]` `Self` group) and `.golangci.yml` has no exhaustiveness linter, so each of them **enumerates every member and ends in a `default:`** (the two key handlers `logx.Errorf` there; the three renderers cannot, they run per frame) and the trailing `selfStateCount` sentinel drives `TestSelfStateSitesAreExhaustive`, whose row count is what makes a sixth member fail there before it can ship half-handled. + - **Rendering**: `renderStatusBar` asks `selfBannerCells()` **once**, immediately after the `statusMsg` branch — not once per focus branch, since the three normal focus states are the only paths left below, so one check covers all three and cannot drift. Order stays `statusMsg > banner > hints` (a transient status outranks the banner and expires by itself). `selfOffered` renders two cells — `keeptui available — [U] update` (the tag in `ui.UpdateAvailableStyle`) and `[X] dismiss`; `selfUpdated` renders `keeptui updated — [U] restart` and `[X] later`. **Announcement and main action are fused into one cell** on purpose: a separate `[U] update` cell would be the first thing `renderHintsBar` drops on a narrow terminal, leaving an announcement with no way to act on it. The render is deliberately **not** gated on `selfCheckEnabled()` — `selfState` is the single source of truth and every write to it already went through that gate (`selfCheckMsg` comes from the command `Init` only queues when enabled, `updateDoneMsg` writes only under `isSelfUpdate`, `[X]` only moves an existing state); a second gate would add a "state exists, banner invisible" mode. That makes the *writers* the invariant to protect: a write that skips the gate turns the whole UI on where the docs promise none. The banner is rendered *through* `renderHintsBar`, so the **API-usage gauge keeps its corner underneath it** (a full banner has no compact cell to compete with); after `[X]` the compact cell *outranks* the gauge and can push it off a narrow bar — the one case where the gauge is not "always in the right corner". + - **Right group** (`renderHintsBar`): `selfCompactCell()` is the banner's collapsed form and the **second** right-aligned element next to the gauge — `keeptui ↑ [U]` (`selfDismissed`), `keeptui [U] restart` (`selfUpdatedLater`), `keeptui updating…` (`selfUpdating()`, the only in-flight indicator besides the live log, since an untracked keeptui has no card for the spinner). Degradation is a fixed candidate list: full gauge + cell → compact gauge + cell → cell alone → compact gauge. (There is no "full gauge alone" step *after* the cell: `place()` fails monotonically in width and the full gauge is always wider than the cell, so it could never fit where the cell did not.) **The self cell outranks the gauge** because it is actionable while the gauge is a read-only reminder; its width rides the same `gap` math (`rateGaugeMinGap`). It also outranks the trailing *hint* cells: when a cell exists, `renderHintsBar` keeps dropping hints for it (never the leading one) — without that it would never fit the 80×24 baseline at all, and after `[X]` it is the feature's only visible surface for the rest of the session (`TestSelfCompactCellFitsBaselineTerminal`). `↑` is East-Asian Ambiguous like the list markers, and since the cell is measured with `lipgloss.Width`, an over-wide measurement can only drop it earlier, never overflow the line. + - **Keys**: `case "U"`/`case "X"` sit in `Update()`'s normal-mode branch, so like `L` they are structurally unreachable from any input mode (a capital `U` typed into search/note/token stays text). `case "U"` is one line — **`m.selfUpdateKey() tea.Cmd`** (model.go) owns the two guards and the state switch, the same named-helper-returning-a-`tea.Cmd` shape every other multi-step key uses (`toggleGroupByTag`, `switchHelpMode`, `refreshSelectedCmd`). **At `selfNone` the key is unbound** — that check comes **first**, ahead of the busy guard, and the order is load-bearing: `selfNone` is the only state a dev build ever reaches, so a busy check in front of it made `U` answer `another update is running` during any ordinary tool update on a working copy — one audible piece of a self-update surface that is documented as absent end to end (`TestSelfUpdateKeyInertWithoutBanner`, whose dev-build and pseudo-version rows fail the moment the two swap back). *With* a banner up, `updatingFor != ""` makes `U` start nothing and **say so** — the shared `updateBusyStatus`, one update at a time in both directions (`TestSelfUpdateKeyInertWithoutBanner`'s blocked rows for `[U]` under either kind of update, `TestToolUpdateKeyBlockedBySelfUpdate` for `[u]` under a self-update); a silent no-op would contradict a banner that still advertises `[U]`, and during a *tool* update the only other indicator is a card spinner invisible unless that tool is selected. `selfOffered`/`selfDismissed` → `detectUpdateCmd(m.selfTool(), true)`; `selfUpdated`/`selfUpdatedLater` → restart. `X` deliberately has **no** `updatingFor` guard: pressed during a self-update it flips the state invisibly (no banner is drawn while `selfUpdating()`) and the fold is what the bar shows when the update lands — a failure writes no state at all, and a success is the one outcome that overrides it (`selfUpdated`, because a restart offer the user has not seen yet cannot arrive pre-folded). See **Completion**. + - **Detect → confirm**: `selfTool()` returns the tracked `keeptui` entry via `toolByName` when there is one (so an `update_cmd` override governs the self-update exactly as it governs `[u]` on that row — `m.tools` is the same `ToolsFromMeta` projection `[u]` uses, rebuilt on every tracker mutation, so both paths get the identical `loader.Tool`), otherwise the synthesized `loader.Tool{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo}` (the host-prefixed shape every real `Tool.GitHub` carries — `updater.Detect` ignores the field, but a bare `owner/repo` there would be a trap for its next reader). `detectUpdateCmd(t, true)` tags the result `self: true`, which is what tells `acceptsUpdateDetect` to skip the selection check — the mode and `updatingFor` gates apply to both paths. A dropped result changes nothing: the banner stays `selfOffered`, where `[U]` is the retry. `ErrUnknownManager` → `statusMsg "no known updater for keeptui — update manually"` (rare since the brew-by-name fallback: it means a hand-downloaded binary) — chosen by `isSelfUpdate(msg.tool)`, so `[u]` on a tracked `keeptui` row gets the same sentence and a dev build's `[u]` gets the tool one (`TestSelfDetectedUnknownManager`). `updateConfirmUpdate` has **one** enter path for both kinds: it takes `m.updateTarget` (empty = nothing to confirm, cancel), sets `updatingFor`/`updateLogFor` to it, resets the log, and derives the panel claim through the same discriminator every other site uses — `m.selfUpdateLog = m.selfUpdating()`, read *after* `updatingFor = target`, so a build with the feature off keeps keeptui's row as per-tool sticky as any other row. That single assignment is also the *release*: a tool update taking the one log buffer over takes the claim with it (`TestToolConfirmEnterReleasesSelfLog`), or that tool's log would render under every row. Nothing here reads `selectedMeta()`, which with an empty tracker used to make enter silently cancel a self-update. + - **`showsUpdateLog()` — the one predicate for "who owns `[3]`"**: `selfUpdateLog || (updateLogFor != "" && selected.Name == updateLogFor)`. It takes **no argument** and resolves the selection itself, deliberately unlike the two-forms-of-one-rule shape (`showsUpdateLog(mt)` plus a separate empty-list variant) that the predicate exists to prevent — and because `renderHelpContent` must answer *before* it has an `mt`: its log branch is hoisted **above** the `No tool selected` early return, since a self-update's log is tied to no selection and the tracker may be empty. Every `updateLogFor`-bound site goes through it: the inset `[3] Update` title (`renderHelp`), that log branch, `updateChunkMsg`'s repaint/autoscroll, `recordUpdateFailure`'s repaint, `setHelpContent`'s entry gate (without it `helpEntries` would be computed for the *selected tool* and `j`/`k` would drive a spotlight over log lines — `TestSelfUpdateLogSuppressesHelpNav`) and `autoFetchCmdsForSelected` (`TestSelfUpdateLogSkipsHelpFetch`). The flag itself is written at exactly one place — the confirm dialog's enter, as `m.selfUpdating()` — so it is a *derived* value that only survives a dismissal. Release is `dismissSelfLog()`, which drops **only** `selfUpdateLog` and never `updateLogFor` (a tracked keeptui keeps the same per-tool stickiness as any other tool), refuses while the self-update is live, and is called from `selectMeta` and `switchHelpMode` — in the latter **before** the `selectedMeta` guard, repainting `[3]` itself when it actually dropped, because on an empty tracker nothing else would. + - **Completion**: the self branch of `updateDoneMsg` runs **before** the `toolByName` early return — for an untracked keeptui that lookup gives `!ok`, the message would be dropped, and `[U] restart` would never appear. The discriminator is **`m.isSelfUpdate(msg.tool)`**: an update of keeptui is a self-update whichever key started it, so on a release build `[u]` on a tracked `keeptui` row ends here too and gets the same restart offer (it used to take the tool path, which left the banner announcing an update the card already showed as installed, with `[U] restart` reachable only by running the whole update again — `TestKeeptuiUpdateSelfHandlingGatedOnBuild`'s release row), while on a dev build that same keypress falls through to the plain tool path below (`TestKeeptuiUpdateSelfHandlingGatedOnBuild`) — the name alone as the discriminator was what let a build with the feature off announce `keeptui updated — [U] restart` and then re-exec its own working copy. Success → `selfState = selfUpdated` + `statusMsg "updated keeptui"`, plus `fetchInstalledCmd` **only when tracked** (nothing else has a card or a `↑` to refresh); note the success write is legitimate even from `selfNone` — a rate-limited startup check plus `[u]` on a tracked row is exactly that, and the new binary really is on disk. Failure → `statusMsg "update failed — see [3]"` + the shared `recordUpdateFailure` and **no `selfState` write at all**: the banner reappears by itself once `updatingFor` clears, so every write here could only walk a state back — over an `[X]` folded mid-update (a deliberate "not now"), over a pending `[U] restart` from an earlier successful update, or over `selfNone`, where there is no `selfLatest` and the forced `selfOffered` rendered `keeptui available` with a hole in it that no later `selfCheckMsg` could fill (that handler writes only from `selfNone`). `TestSelfUpdateDoneFailureKeepsPriorState` pins all five prior states. `updatingFor` is cleared unconditionally in both. + - **Restart**: `[U]` at `selfUpdated`/`selfUpdatedLater` sets `m.restartRequested` and returns `tea.Quit` — `selfState` is deliberately not moved (after `tea.Quit` nothing renders, and a failed exec exits anyway, so a sixth enum member would be dead). `RestartRequested()` is the exported accessor `main` reads off the model `p.Run()` returns, **strictly after** it returned: Bubble Tea has restored the terminal by then, whereas exec'ing from inside `Update` would hand the new process an alt screen it never opened. `restart_unix.go`'s `restartSelf()` delegates to the seam-injected `restartSelfWith(resolve, execve, out)` (the `exists`/`lookPath` idiom again, so both failure paths are testable) = `selfPath()` + `syscall.Exec(path, os.Args, os.Environ())` — same pid, same argv, same env, so the tab/tmux pane survives. Only a failure logs `logx.Errorf("restart self: …")` (a genuine anomaly) and prints the shared `restartHint` (`keeptui updated — run keeptui again`) to **stdout** at exit code 0 — the update did succeed, so this is an instruction, not diagnostics; a successful exec never comes back, so the whole tail is unreachable on success and says nothing. `restart_windows.go` prints the same hint and nothing else: there is no image-replacing exec, and spawning a child from a process about to exit hands the new keeptui a console the old one still owns. It logs nothing — planned degradation, not a malfunction. + - **Path resolution** (`restart_unix.go`, pure core + thin wrapper): `resolveSelfPath(executable, exists, lookPath, argv0)` mirrors how a shell resolves what the user typed instead of trusting `os.Executable`. An argv0 carrying a path separator (`strings.Contains(argv0, "/")` — the file is `//go:build !windows`, so `/` is the only separator that can reach it; the backslash and the `.exe`-trimming in `sameProgram` that the core carried while it lived in the untagged `restart.go` were dead branches under the tag and are gone, along with the two test rows that exercised them) is already a path the user pointed at and wins when it `exists`, with no `lookPath` call — a same-named binary earlier in `PATH` must not hijack `./keeptui`. A **bare** argv0 goes through `lookPath` **first**, and this order is load-bearing on **Linux**: there `os.Executable()` reads `/proc/self/exe` symlink-*resolved*, so after a keg-style upgrade it can still name the live *old* binary, and exec'ing that would loop the feature (restart → the same banner → restart). Two things disqualify a `PATH` hit: `("", nil)` counts as a miss (an empty string must never reach `syscall.Exec`), and so does a hit whose `filepath.Base` differs from `executable`'s (`sameProgram`) — argv0 comes from the *parent* process, so a wrapper using `exec -a` or a shell that rewrote `argv[0]` would otherwise make the restart exec an unrelated program with keeptui's argv and environment. `executable` is the fallback for both branches and only when it `exists`; nothing left → error → `restartHint`, no exec. **Known asymmetry**: `updater.Detect` resolves the update *target* from `exec.LookPath(t.Name)`, so a release binary launched by path (`./keeptui`) while another keeptui sits on `PATH` updates the `PATH` one and then re-execs the by-path (un-updated) binary — the banner comes back. Fixing it means changing where `Detect` starts, which is shared with `[u]` for every tool and out of this feature's scope; README lists it as a caveat. +- **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]` (only in `focusHelp` — `case "r"` branches on focus: `focusTools` rename, `focusBrief` refresh), `[h]` and `[m]` (both fire from `focusBrief || focusHelp`) switch it. All three go through the shared **`switchHelpMode(mode)`** (model.go), which sets the mode, dismisses a *completed* update log (a tool's via `updateLogFor`, keeptui'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 `[h]`/`[m]` also fire from `[2]` and move it while `[r]` is `focusHelp`-only. 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) sanitizes with `cleanTerminalOutput`, then runs glamour with a **fixed** `WithStandardStyle("dark"|"light")` — 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 — plus `WithWordWrap(helpWrapWidth())` and `WithColorProfile(lipgloss.ColorProfile())` (glamour hardcodes TrueColor and would ignore `NO_COLOR`/dumb terms); a glamour failure falls back to the sanitized plain text, never an empty panel. The result is memoized in the single-entry `readmeRenderCache` keyed by `(name, raw, width, dark)` — `setHelpContent` runs on every selection move and width resize and must not re-parse a large README each time. `parseHelpEntries`/`colorizeHelp` are **not** run in readme mode (glamour output is already ANSI): `helpEntries` stays empty so `j`/`k` are plain scroll, and `/` (help search) is a no-op — guarded at the shared `focusBrief || focusHelp` entry point, so the `[1]` tool-list search is unaffected; the `focusHelp` status bar drops the `[/] search` hint in readme mode rather than advertising a dead key. Placeholders are tool-named (`readmeContent` in render.go): no GitHub ref → `No repo for . Press [h] for --help.`, in-flight → `Loading...`, `ErrNoReadme` → `No README in . Press [h] for --help.`, `ErrRateLimited` → `rate limited — press [L]`, and a generic fallback `No README for . Press [h] for --help.` for everything else — a network/5xx failure **and** the case where content exists but glamour rendered it to nothing (a badges/HTML-comment-only README). That last one is why the "real content" test is `data.content != "" && m.helpBase != ""`: returning an empty render as content paints a blank panel with no way out. - **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-2, 20)`) 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 (`renderHelpContent`'s normal path serves `applySpotlight(helpBase)`; the base is built directly in `setHelpContent`, not via `renderHelpContent`, which can be in the search-highlight branch). **`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 (help-search keystrokes, 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 (`ui.HelpDimStyle.Render(stripANSI(line))` — the `[L]` 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, `/` help-search entry, 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 is managed from `focusTools`**: `t` track (add by GitHub URL or plain name → `modeTrack`), `u` untrack (with confirmation → `modeConfirmUntrack`), `r` rename (fix the binary name when the repo name differs → `modeRename`). 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 keeptui. `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) — keeptui 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`/`modeHelpSearch` exits; `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 keeptui'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 keeptui 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). -- **Help bar** (`renderStatusBar()`) is per-focus; the `focusTools` bar leads with `[enter] run`; the `focusBrief` bar shows the action keys `[o] open repo [c] changelog [r] refresh [s] status [e] note [t] tags [q] quit [?] keys`, the `focusHelp` bar the three mode keys `[h] --help [m] man [r] readme` after the scroll/nav hints. All three normal focus bars end with the `[?] keys` hint (opens the hotkeys overlay). In the three normal focus states `renderHintsBar` right-aligns a **GitHub API Usage gauge** to the corner (a fixed 12-cell bar of yellow `▮` fill / darker-yellow `░` track glyphs showing *used/limit*, e.g. `45/60`, plus `[L] details`). The bar is foreground-colored glyphs, not painted backgrounds, so it survives degraded color profiles; both glyphs are deliberately non-East-Asian-Ambiguous (a `█` would measure two cells under `RUNEWIDTH_EASTASIAN=1` and break the right-alignment math). `gaugeFilled` clamps the rounded ratio so any usage shows at least one `▮` (with limit 5000 the first cell would otherwise need ~209 requests) and a full bar means exhaustion only. The bar width is constant regardless of the 60 vs 5000 limit; on narrow terminals it downgrades full → compact (`GH 45/60 [L]`) → hidden (`rateGaugeMinGap`). Colors are constant yellow — no rate-pressure recolor (the `⚠`/`✕` alarm lives only in the `[L]` overlay). Input/modal states show no gauge. **The bar must stay one line**: `HelpStyle` is width-constrained, so a hint list wider than `m.width-2` wraps to a second row and `View()` returns `m.height+1` lines — one row past the terminal, which scrolls the top border off the alt screen. `renderHintsBar` therefore takes the hints as `[]string` cells ordered most-important-first and drops them from the right until they fit (at 80 columns the `focusBrief` and `focusHelp` bars lose `[?] keys` and `[q] quit`, which the `[?]` overlay still lists) before it places the gauge. `TestStatusBarNeverWraps` pins the invariant at the 80×24 baseline. -- **Status-message lifecycle**: `m.statusMsg` (rendered by `renderStatusBar`, which outranks the hints bar whenever non-empty) has two clears. (1) **Immediate**: the blanket `m.statusMsg = ""` on every `tea.KeyMsg` — any keypress wipes the current status at once. (2) **TTL auto-expiry**: every *transient* status is set via **`setStatus(s) tea.Cmd`** (model.go), which bumps a generation counter `m.statusSeq`, sets the message, and returns `tea.Tick(statusMsgTTL, …)` producing a `statusExpiredMsg{seq}` stamped with the current seq; the `Update` handler clears the message only when `msg.seq == m.statusSeq`, so a stale timer from a message already superseded by a newer one is a harmless no-op. `statusMsgTTL` is a **var** (`1 * time.Second`), shrunk by tests the same way as `launchTimeout` (the tick captures the value at construction, so the shrink must precede the `Update`). The returned `tea.Cmd` must be **batched** into whatever the caller already returns (`tea.Batch`); callers that only set a status return it directly. **In-flight** statuses (`launching in …`, `still launching `) are the deliberate exception — plain assignments, no timer: they report work still in progress and are extinguished by `launchDoneMsg`, not the clock, so letting a timer expire one mid-flight would hide the only sign the adapter is busy for up to `launchTimeout`. `TestStatusExpired*`/`TestSetStatus*` pin the mechanism; `assertOnlyExpiryTick` is the test helper that asserts a site returns *only* the expiry tick (no fetch/exec rode along). +- **Help bar** (`renderStatusBar()`) is per-focus; the `focusTools` bar leads with `[enter] run`; the `focusBrief` bar shows the action keys `[o] open repo [c] changelog [r] refresh [s] status [e] note [t] tags [q] quit [?] keys`, the `focusHelp` bar the three mode keys `[h] --help [m] man [r] readme` after the scroll/nav hints. All three normal focus bars end with the `[?] keys` hint (opens the hotkeys overlay). In the three normal focus states `renderHintsBar` right-aligns a **GitHub API Usage gauge** to the corner (a fixed 12-cell bar of yellow `▮` fill / darker-yellow `░` track glyphs showing *used/limit*, e.g. `45/60`, plus `[L] details`). The bar is foreground-colored glyphs, not painted backgrounds, so it survives degraded color profiles; both glyphs are deliberately non-East-Asian-Ambiguous (a `█` would measure two cells under `RUNEWIDTH_EASTASIAN=1` and break the right-alignment math). `gaugeFilled` clamps the rounded ratio so any usage shows at least one `▮` (with limit 5000 the first cell would otherwise need ~209 requests) and a full bar means exhaustion only. The bar width is constant regardless of the 60 vs 5000 limit; on narrow terminals it downgrades full → compact (`GH 45/60 [L]`) → hidden (`rateGaugeMinGap`). Colors are constant yellow — no rate-pressure recolor (the `⚠`/`✕` alarm lives only in the `[L]` overlay). Input/modal states show no gauge. The gauge is no longer the *only* right-aligned element: the collapsed self-update cell shares the corner with it and outranks it (see **Self-update** for the candidate order). **The bar must stay one line**: `HelpStyle` is width-constrained, so a hint list wider than `m.width-2` wraps to a second row and `View()` returns `m.height+1` lines — one row past the terminal, which scrolls the top border off the alt screen. `renderHintsBar` therefore takes the hints as `[]string` cells ordered most-important-first and drops them from the right until they fit (at 80 columns the `focusBrief` and `focusHelp` bars lose `[?] keys` and `[q] quit`, which the `[?]` overlay still lists), then drops more for a collapsed self cell, and only then places the right group. The **leading** cell is never dropped, so one cell wider than the whole bar is possible — the fused self banner is 37 cells and overflows below ~39 columns — and that case is **truncated** (ANSI stripped first, since a cut inside an escape sequence would be re-emitted to the terminal verbatim) rather than allowed to wrap. `TestStatusBarNeverWraps` pins the invariant at the 80×24 baseline and, for the banner, down to 24 columns and with a long upstream tag; the gauge cases seed `m.rate.Known = true`, because with an unknown rate no gauge is drawn at all and the right group's geometry would go unchecked. Below 80 columns only the *bar* is checked: the three panels have their own minimums (15+30+30 plus borders), so the layout overflows there on its own — a separate, pre-existing limit. +- **Status-message lifecycle**: `m.statusMsg` (rendered by `renderStatusBar`, which outranks both the self-update banner and the hints bar whenever non-empty — the banner is not modal, and a transient status covering it for `statusMsgTTL` is intended) has two clears. (1) **Immediate**: the blanket `m.statusMsg = ""` on every `tea.KeyMsg` — any keypress wipes the current status at once. (2) **TTL auto-expiry**: every *transient* status is set via **`setStatus(s) tea.Cmd`** (model.go), which bumps a generation counter `m.statusSeq`, sets the message, and returns `tea.Tick(statusMsgTTL, …)` producing a `statusExpiredMsg{seq}` stamped with the current seq; the `Update` handler clears the message only when `msg.seq == m.statusSeq`, so a stale timer from a message already superseded by a newer one is a harmless no-op. `statusMsgTTL` is a **var** (`1 * time.Second`), shrunk by tests the same way as `launchTimeout` (the tick captures the value at construction, so the shrink must precede the `Update`). The returned `tea.Cmd` must be **batched** into whatever the caller already returns (`tea.Batch`); callers that only set a status return it directly. **In-flight** statuses (`launching in …`, `still launching `) are the deliberate exception — plain assignments, no timer: they report work still in progress and are extinguished by `launchDoneMsg`, not the clock, so letting a timer expire one mid-flight would hide the only sign the adapter is busy for up to `launchTimeout`. `TestStatusExpired*`/`TestSetStatus*` pin the mechanism; `assertOnlyExpiryTick` is the test helper that asserts a site returns *only* the expiry tick (no fetch/exec rode along). - **API-status overlay (`L`)**: opens a read-only view of the GitHub rate limit and token (source, masked value, used/limit with threshold icon, reset time) with token entry/removal/refresh. When no token is configured it leads with an `Add a GitHub token…` nudge (hidden once a token exists or while entering one). It is `modeAPIStatus` (token entry: `modeTokenInput`) with a matching `renderStatusBar()` branch; `L` fires only in `modeNormal`; `esc` **or `q`** closes it. See the GitHub API section for the data flow. - **Overlay compositing**: two overlays composite over the layout via `ui.PlaceOverlay` (a centered fg-over-bg compositor), gated by the shared `overlayVisible()` predicate in `View()` — the `[L]` API-status overlay (`renderAPIStatus`) and the `[?]` hotkeys overlay (`renderHotkeys`), picked by `m.mode`. `PlaceOverlay` dims the whole visible background — original styling is stripped and repainted with `OverlayDimStyle` (`ColorDim`) — so the modal is the only full-color element. Covered rows get the dim inside `overlayLine` *after* `truncateVisible`/`dropVisible`, because those helpers `StripANSI` the bg and would erase a pre-applied dim from the modal's side margins. - **Mouse policy** (`handleMouse` in `render.go`, dispatched from `Update()` before the mode switch, gated inside the function): wheel scrolling works in every mode; while any overlay is visible (`overlayVisible()` — `[L]` API status or `[?]` hotkeys) all mouse input is a no-op, and before the first `WindowSizeMsg` (`!m.ready`) too. Clicks that change selection or focus fire only in `modeNormal` — otherwise a click would move `selectedMeta()` under an open note/tags/rename editor and retarget the commit. A click that changes the selected tool goes through the same `selectMeta` helper as the keyboard `j`/`k` path, including the auto-fetch; a click anywhere in the tools panel (row or empty area) focuses it via `setFocus`, matching brief/help — the same helper the keyboard uses, so a click cannot leave the list painted with stale focus styling. Both panels translate the click row the same way — **through `panelRow(msg.Y, vp.Height)`**, then `+ vp.YOffset`. X alone does not mean "inside a panel": the outer `Margin(1,0)` row, the two borders and the status/hints bars all share the panels' columns, and with a scrolled viewport an unbounded `msg.Y - 2` maps that chrome onto real content — a click on the blank top row would open a card link. `panelRow` returns `-1` outside the viewport's rows; inside, `[1]` goes through `toolAtLine()` (a group header maps to `-1` and selects nothing) and `[2]` through `buildCard()`'s link index — a click on the `repo:` line or the changelog release URL returns `openURLCmd(url)`, every other line only moves focus. @@ -111,7 +125,7 @@ The base dir is resolved by `configdir.Base()`: `~/.config/keeptui` on **macOS a | Data | Location | |---|---| | Tracker metadata | `~/.config/keeptui/meta.yaml` | -| Version cache (24h TTL) | `~/.config/keeptui/cache.json` | +| Version, README and self-check cache (24h TTL each, separate timestamps) | `~/.config/keeptui/cache.json` | | GitHub token (`0600`) | `~/.config/keeptui/token` | | Session error log (lazy, per session) | `~/.config/keeptui/logs/keeptui-.log` | | Pre-migration tracker copy (only when a load dropped tags) | `~/.config/keeptui/meta.yaml.bak` | @@ -128,17 +142,19 @@ The base dir is resolved by `configdir.Base()`: `~/.config/keeptui` on **macOS a The internal `testConfigDir`/`testCacheDir`/`testTokenDir`/`testAPIBase`/`testBrewPrefix`/`testHomeDir` vars still exist for per-test setup (`testBrewPrefix` exists **twice** — once in `version`, once in `updater` — each private to its package, so an override must name the right one; `version`'s `TestMain` additionally pins its copy to an empty throwaway prefix package-wide, which is why a test there sees no brew layout unless it sets one up); the exported seams exist because the package that can *reach* a file is usually not the one that owns it — a `model` test that drives the tags/track/rename/untrack/status handlers lands in `loader.SaveMeta`, which rewrites `meta.yaml` **wholesale**, and one that drives the `[L]` overlay lands in `version.SetToken`. Leaving that to each test to remember (with its own temp `HOME`) is not a safeguard: an ad-hoc probe test that forgot it destroyed a real user's tracker, which is why the isolation moved to `TestMain`. Each of those packages carries a `TestConfigDirIsolated` test whose only job is to fail loudly if the isolation is ever removed. Never write a test that reaches a config path without one of these seams active. +`testAPIBase` is **not** exported, so a `model` test cannot point a fetch at an httptest server — executing any network command there would hit the live API. For `selfCheckCmd` the way around it is a **warm cache**: `seedSelfReleaseCache` (selfupdate_test.go) adds a fresh `ReleaseCheckedAt` entry for `version.SelfRepo`, so `version.SelfLatest` answers from `cache.json` without a request. It **snapshots the whole cache and restores it in `t.Cleanup`** rather than writing a one-entry `version.Cache` over it: `cache.json` is shared package-wide by `TestMain`'s `version.SetConfigDirForTesting`, so a clobbering seed would leak into every later test and a case needing both a README and a release seed would silently lose one. Hermeticity is asserted, not assumed — `TestSelfCheckCmdServesCache` checks `ReleaseCheckedAt` is *unchanged* afterwards, and any real request would have re-stamped it. `Init`'s batch test executes only element 1 (the self-check, right after the rate seed) against that seed; the rest of the batch is judged by **length** (the `TestInitHelpProbeFollowsHelpMode` pattern), and the README seed must stay the batch's last element. + ### Session error log (`internal/logx`) An errors-only journal so bugs can be researched after the fact instead of reconstructed from memory. One plain-text file per session, created **lazily on the first write** — a session with no errors leaves no file at all, so the presence of a file is itself the signal. The timestamp is colon-free (Windows filenames) and zero-padded, so lexicographic order equals chronological order, which is what `Cleanup()` (keep newest 20) relies on. The header (`keeptui / tools= token=`) is assembled in `main.go` via `logx.SetHeader` and written as the file's first line — it stays in `main` because pulling version/tool-count/token-source into `logx` would invert the import graph (`logx`'s only project import is the stdlib-only `configdir` leaf, keeping it near the bottom of the graph). **Why our `recover` sits deeper than Bubble Tea's:** `recover()` consumes a panic wherever it fires first, and Bubble Tea's own recover in each `tea.Cmd` goroutine prints the trace *after* `p.cancel()` (async) but *before* the terminal is restored — so the trace lands in the alt-screen buffer and vanishes on exit. `logx.Recover(context)` is therefore deferred *inside* `Update`, `View` and every command (via the `safeCmd` wrapper; `execToolCmd` is the one unwrapped cmd — `tea.ExecProcess` only constructs the exec message, nothing there can panic), records the value plus `debug.Stack()` (which still contains the real panic site from inside the defer), then **re-panics** so Bubble Tea still catches it, restores the terminal and returns `ErrProgramPanic`. `tea.WithoutCatchPanics` is deliberately **not** used — terminal restoration is Bubble Tea's job and it does it correctly. Errors-only by design: there is no debug level, no `KEYS_DEBUG`, no env reading, no JSON, no size-based rotation — without a debug level the file appears only when something went wrong. The logger never breaks the app: its own failures (file won't open, disk full) are swallowed silently. -Logging sites are `Errorf`-only: cache read/write failures (`version.LoadCache`/`SaveCache`), GitHub API failures (`doGH`/`classifyStatus` — HTTP code + `X-RateLimit-Remaining`, never the token), installed-version detection give-up (`InstalledVersion`, logged once — but only when a binary that *is* on PATH fails to answer `--version`/`-V`; a plain not-on-PATH miss is the normal "not installed" state and is never logged, so a tracked-but-uninstalled tool does not create a log every startup. A brew/cargo fallback hit and an `isTUITakeover` capture both suppress it too — the first is that path's normal state, the second is a classified kind of tool rather than a malfunction), `meta.yaml` save failures (`loader.SaveMeta`), help-capture failures (`fetchHelpCmd`), and a panic-ended session from `main`. `View`/render steady state and keystrokes are deliberately never logged. +Logging sites are `Errorf`-only: cache read/write failures (`version.LoadCache`/`SaveCache`), GitHub API failures (`doGH`/`classifyStatus` — HTTP code + `X-RateLimit-Remaining`, never the token), installed-version detection give-up (`InstalledVersion`, logged once — but only when a binary that *is* on PATH fails to answer `--version`/`-V`; a plain not-on-PATH miss is the normal "not installed" state and is never logged, so a tracked-but-uninstalled tool does not create a log every startup. A brew/cargo fallback hit and an `isTUITakeover` capture both suppress it too — the first is that path's normal state, the second is a classified kind of tool rather than a malfunction), `meta.yaml` save failures (`loader.SaveMeta`), help-capture failures (`fetchHelpCmd`), update failures (`recordUpdateFailure`, shared by the tool and self paths — note the seed of the *on-screen* reason is owner-gated on `updateLogFor == msg.tool` while the log line is unconditional), a failed unix restart (`restartSelf` — a path-resolution miss or an exec refusal; the Windows branch logs nothing, its hint is planned degradation), and a panic-ended session from `main`. `View`/render steady state and keystrokes are deliberately never logged. ### GitHub API -Unauthenticated the REST API allows **60 requests/hour** per IP; with a token it is **5000/hour**. Each tool with a `GitHub` field costs 3 requests at startup (`fetchRelease` + `fetchRepoInfo` + `fetchLanguages` inside `GetRepoData`), so a cold start with many tools and no token can exhaust the quota. The README adds **one lazy request per visited tool per 24h** — not per tracked tool: it is fetched only for the tool whose panel `[3]` actually shows it (see the async fetch split), and a 404 or rate-limit answer is session-cached, so re-visiting a tool never re-spends it. Note the flip side of the README being the default mode: walking the list *does* spend one request per row visited for the first time. A token raises the limit and is resolved with **env precedence**: `GITHUB_TOKEN` env var always wins, otherwise the config-file token (`~/.config/keeptui/token`, `0600`, loaded lazily once via `sync.Once`). +Unauthenticated the REST API allows **60 requests/hour** per IP; with a token it is **5000/hour**. Each tool with a `GitHub` field costs 3 requests at startup (`fetchRelease` + `fetchRepoInfo` + `fetchLanguages` inside `GetRepoData`), so a cold start with many tools and no token can exhaust the quota. The README adds **one lazy request per visited tool per 24h** — not per tracked tool: it is fetched only for the tool whose panel `[3]` actually shows it (see the async fetch split), and a 404 or rate-limit answer is session-cached, so re-visiting a tool never re-spends it. Note the flip side of the README being the default mode: walking the list *does* spend one request per row visited for the first time. keeptui's own self-check adds **one release-only request per 24h window** on release builds (none on a dev build), and a tracked keeptui pays it from the shared cache entry — a fresh full repo pass makes the self-check free. A token raises the limit and is resolved with **env precedence**: `GITHUB_TOKEN` env var always wins, otherwise the config-file token (`~/.config/keeptui/token`, `0600`, loaded lazily once via `sync.Once`). **Token (`token.go`):** `resolveToken()` returns env token else `tokenMem` (all `tokenMem` access under `tokenMu`, `-race` clean). `SetToken(t)` writes the `0600` file (`MkdirAll` for the dir) and updates memory; `ClearToken()` removes both; `TokenSource()` reports `"env"|"config"|"none"`; `Token()` returns the effective token (env precedence) for the overlay's masked preview. Env source never persists — the config file only holds a TUI-entered token. @@ -158,4 +174,8 @@ The `version` package caches responses in `cache.json`. URL→`owner/repo` norma **README freshness is a separate timestamp.** `CacheEntry` carries `Readme string` plus its own `ReadmeCheckedAt time.Time` (`readme_checked_at,omitzero` — `omitempty` is a no-op on a struct field and the linter flags it), deliberately **not** the shared `CheckedAt`: `getRepoData`'s freshness gate is `CheckedAt`-only with no content check, so a successful README fetch stamping it would mark a rate-limited (deliberately stale) repo fetch as fresh — blank card, no retry for 24h. The two poison-guards stay independent. A cache hit needs `time.Since(ReadmeCheckedAt) < cacheTTL && Readme != ""`; a failed fetch never advances `ReadmeCheckedAt`, and a non-`ErrNoReadme` failure still returns the cached `Readme` (known content wins). The body is read through an `io.LimitReader` capped at `readmeMaxBytes` (512 KiB) with a `*(README truncated)*` marker appended when it trips: the blob goes into the shared `cache.json` and is glamour-parsed **synchronously inside `Update()`**, so an outsized README would both bloat the cache file and stall a keystroke. -**Force refresh** (`[r]` in `focusBrief`): `GetRepoData`/`GetChangelog`/`GetReadme` are thin wrappers over `getRepoData(field, force)`/`getChangelog(field, force)`/`getReadme(field, force)`; `force` skips **only** the freshness short-circuit, reusing the same fetch + `updateCacheEntry` merge + conclusive-timestamp guard. `RefreshRepoData`/`RefreshChangelog`/`RefreshReadme` (`force=true`) re-fetch on demand while still respecting the poison-guard — a forced refresh that hits a rate limit on repo-info does not stamp the entry fresh-but-blank. +**The self-check's freshness is a third timestamp.** `SelfLatest()` (`selfcheck.go`, with the exported `SelfRepo = "stanlyzoolo/keeptui"`) is a **release-only** pass — `fetchRelease` alone, never `/repos/{repo}` or `/languages` — so a startup self-check costs one request per window instead of three. It stamps `CacheEntry.ReleaseCheckedAt` (`release_checked_at,omitzero`) and never `CheckedAt`, for exactly `ReadmeCheckedAt`'s reason: `getRepoData`'s gate is `CheckedAt`-only with no content check, so a release-only pass advancing the shared timestamp would mark a never-fetched repo card as fresh and serve a blank card for the whole TTL. Each poison guard owns its timestamp. A cache hit is `selfCachedTag(e) (string, bool)` — it returns the *answer*, not just freshness: a fresh `ReleaseCheckedAt` **alone** answers (this function is its only writer and only stamps it conclusively) **or** a fresh `CheckedAt` **plus an answer left behind by that pass** — a non-empty `Latest` or the recorded negative below (the full pass's gate has no content check, so there something must actually be there). `errNoReleases` (404) is conclusive: the stamp lands, `("", nil)` goes out, and a repo without releases is not re-probed every launch. A transient failure (rate limit, network, 5xx) returns `("", err)` and stamps nothing — deliberately with **no stale fallback** to a cached `Latest`, unlike the README: offering an update out of an expired window is a UI action, not showing known content. On success the mutation writes the whole release tuple — `Latest`/`Body`/`HtmlUrl`/`PublishedAt` — in the usual `e := existing` style, so a card or README in the entry survives. `Body` rides along on purpose: `getChangelog`'s gate is `CheckedAt` **and** a non-empty `Body`, and this pass never advances `CheckedAt`, so writing the notes only makes that fallback more accurate — omitting them is what would put the *new* tag and its clickable URL over the *old* release's notes. + +**A 404 is remembered in a flag, never by clearing the tuple** (`CacheEntry.ReleaseMissing`, `release_missing,omitempty`). The release tuple belongs to the *shared* card: `getRepoData` writes it only `if relErr == nil` (so its own 404 preserves the previous values) and `getChangelog` falls back to the cached `Body` on any error — "known content wins". `SelfLatest` used to be the one writer in the package that did the opposite, so a single 404 window on `/releases/latest` (a maintainer converting the only release to a draft to re-cut it, a repo the token cannot see — GitHub answers 404, not 403) stripped a *tracked* keeptui's `latest:` line, its release date, its `↑` marker, the changelog body and the card's clickable release URL, with no `CheckedAt` advance to make any of it recoverable. Both directions are defensible in isolation, but the two passes must not disagree about what a 404 means for the same fields, and the destructive one was the release-only *side* pass rather than the tool's own. So the tuple is preserved and the negative lives in `ReleaseMissing`, which **all three release writers maintain** (`SelfLatest`, `getRepoData`, `getChangelog`: set on a 404, cleared whenever a release is actually fetched) through the single **`applyReleaseOutcome(e, info, err)`** — the one definition of what a `/releases/latest` fetch does to a cache entry (tuple on success only, flag either way; freshness stamps stay each caller's own). The contract was hand-copied into the three writers once and immediately drifted (the changelog pass only ever *cleared* the flag), which is why it is a function and not a convention. It is read only by the self-check, through `selfTagOf(e)` — "which release should the banner offer": nothing while `ReleaseMissing`, the cached tag otherwise. The banner therefore still goes quiet on a 404 (`SelfLatest` answers `""`, and the cached negative keeps answering `""` for the window) while the card keeps everything it had. `getChangelog`'s share of that is a write from its **error** path (the only `ReleaseMissing` write that is not next to a tuple write), and it is load-bearing rather than symmetry for its own sake: when `CheckedAt` is fresh but `Body` is empty — a release published with no notes — `getRepoData` short-circuits and the changelog is the *only* pass still asking `/releases/latest`, so without it a release deleted or converted to a draft would go unobserved and `selfCachedTag`'s `CheckedAt` branch would keep offering the preserved tag for the rest of the window. It writes the flag alone: no tuple change (the cached body is still what the panel gets) and no `CheckedAt` restamp. Pinned by `TestSelfLatestDroppedReleaseKeepsSharedTuple` (tuple survives, flag set, second call served from cache), `TestSelfLatestNegativeSharedWithFullPass` (a full pass's own 404 records the negative, so the self-check does not offer the tag that pass merely preserved — and a later successful full pass retires it without waiting out the TTL), `TestSelfLatestNegativeClearedByChangelogFetch` (the third writer clearing it) and `TestSelfLatestNegativeRecordedByChangelog404` (the same writer setting it, in exactly the fresh-`CheckedAt`/empty-`Body` window). + +**Force refresh** (`[r]` in `focusBrief`): `GetRepoData`/`GetChangelog`/`GetReadme` are thin wrappers over `getRepoData(field, force)`/`getChangelog(field, force)`/`getReadme(field, force)`; `force` skips **only** the freshness short-circuit, reusing the same fetch + `updateCacheEntry` merge + conclusive-timestamp guard. `RefreshRepoData`/`RefreshChangelog`/`RefreshReadme` (`force=true`) re-fetch on demand while still respecting the poison-guard — a forced refresh that hits a rate limit on repo-info does not stamp the entry fresh-but-blank. `SelfLatest` has **no** force variant: the self-check runs once per launch and its answer is only ever consumed by the startup banner, so nothing in the UI can ask for it again. diff --git a/README.md b/README.md index 038be30..7c28d2d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ subcommands; the only flags are `--version` and `--help`. - [Installation](#installation) - [Usage](#usage) - [Updating tools](#updating-tools) +- [Updating keeptui itself](#updating-keeptui-itself) - [GitHub API and token](#github-api-and-token) - [Data storage](#data-storage) - [Architecture](#architecture) @@ -28,6 +29,7 @@ subcommands; the only flags are `--version` and `--help`. - **Tool card** — repository, stars, languages, installed and latest version with release date, status, note and tags - **Versions** — the installed version is detected locally, the latest is fetched from GitHub; an outdated install is marked with `↑` in the list and on the card, and tools with an available update are grouped at the top of the list - **In-TUI updates** — `u` on the card detects the package manager (brew / go / cargo / pipx / npm) or uses `update_cmd` from `meta.yaml`, shows the command for confirmation and streams its output into panel `[3]` in real time +- **Self-update** — when a newer `keeptui` release exists, the status bar offers it: `U` updates through the same pipeline, `X` folds the notice into a compact cell that keeps `U` working, and after the update `U` restarts `keeptui` in place, in the same terminal tab. Works whether or not `keeptui` is in your own tracker - **Run tools** — `enter` on a tool opens a one-line command prompt (it remembers the last command per tool for the session) and launches it in a new terminal tab (tmux / iTerm2 / kitty / WezTerm / Terminal.app); anywhere else the tool runs in the current window and `keeptui` resumes when it exits - **Help navigation** — in `--help` / `man` mode `j` / `k` walk through flags and subcommands with the current entry highlighted; `/` searches the text - **List search** — `/` filters by name and tag with match highlighting and an `N/M` counter @@ -83,6 +85,7 @@ time for the hotkeys overlay — every keybinding, grouped by panel. | `enter` | run the tool: a one-line prompt opens, prefilled with the tool name (or the last command run for it this session — handy for appending arguments); the command opens in a new tab named after the tool where the terminal is scriptable (tmux, iTerm2, kitty; Terminal.app opens a window, WezTerm an unnamed tab), anywhere else it runs in the current window — `keeptui` suspends and resumes when the tool exits. If opening the tab fails, the command automatically runs in the current window instead | | `/` | search by name and tag: the matched substring is highlighted, tag-only matches show the tag dimmed, the status bar shows an `N/M` counter; `↑` / `↓` move through matches, `enter` opens the card, `esc` cancels and restores the previous selection | | `L` | GitHub API status — limits and token (see below) | +| `U` / `X` | update `keeptui` itself / fold the notice away — active from any panel while a new `keeptui` release is offered (see [Updating keeptui itself](#updating-keeptui-itself)) | | `?` | hotkeys overlay — every keybinding, grouped by panel | | `esc`, `q`, `ctrl+c` | quit (`q` / `ctrl+c` quit from any panel; `esc` quits only here — in `[2]` / `[3]` it moves focus back instead) | @@ -116,6 +119,7 @@ selected tool stays selected either way. Both views are display-only. | `t` | edit the tag — one tag per tool; text after the first comma is dropped, an empty value clears it | | `j / k`, `↑ / ↓` | scroll the card (3 lines) | | `ctrl+d / ctrl+u`, `ctrl+f / ctrl+b`, `PgUp / PgDn`, `space`, `g / G` | half-page / full-page scroll, top / bottom | +| `U` / `X` | update `keeptui` itself / fold the notice away — see [Updating keeptui itself](#updating-keeptui-itself) | | `?` | hotkeys overlay | Statuses: `active` (●) · `trying` (○) · `inactive` (✕) — shown on the card. @@ -147,6 +151,7 @@ not per tool — pick `--help` once and moving through the list keeps showing `- | `↑ / ↓` | scroll the text (3 lines) | | `ctrl+d / ctrl+u`, `ctrl+f / ctrl+b`, `PgUp / PgDn`, `space`, `g / G` | half-page / full-page scroll, top / bottom | | `/` | search the text (`n` / `N` — next / previous match); not available in README mode | +| `U` / `X` | update `keeptui` itself / fold the notice away — see [Updating keeptui itself](#updating-keeptui-itself) | | `?` | hotkeys overlay | | `esc` | first turns off entry navigation, then moves focus away | @@ -161,7 +166,11 @@ README, an exhausted quota or a failed fetch show a message with the way out adding a token in the `L` overlay retries the ones that hit the limit. While a tool is being updated, this panel (`[3] Update`) shows the live command log; -the log stays available after completion — until the next update. +the log stays available after completion — until the next update. For a tool the log +belongs to its row: move away and you see that tool's docs again, come back and the log +is there. A `keeptui` self-update log is shown whichever tool is selected (and with an +empty tracker); once it has finished, moving the selection or switching the mode with +`h` / `m` / `r` returns the panel to the docs. ## Updating tools @@ -199,15 +208,78 @@ in `meta.yaml` always takes precedence over auto-detection and runs via `sh -c` update_cmd: mytool self-update ``` +## Updating keeptui itself + +`keeptui` watches its own releases too. On startup it checks the latest one — a single +request, cached for 24 hours; on a build from a working copy the feature is off entirely +— no request, no notice, no restart offer (that covers both `dev` and the pseudo-version +a plain `go build` stamps, `v0.0.0--`, with or without `+dirty`). When a +newer version exists, the status bar replaces the usual hints with a notice: + +``` +keeptui v0.5.0 available — [U] update [X] dismiss +``` + +- `U` — detects how this `keeptui` was installed (the same brew / go / cargo / pipx / + npm detection tools get put through), shows the command for confirmation and streams + its output into panel `[3] Update`. That log is visible whichever tool is selected — + even with an empty tracker. While the update runs, a compact `keeptui updating…` cell + sits in the corner of the status bar. +- `X` — folds the notice into a compact `keeptui ↑ [U]` cell next to the API gauge. `U` + keeps working from there for the rest of the session; nothing is written to disk, so a + dismissed notice comes back on the next launch. + +After a successful update the bar reads `keeptui updated — [U] restart [X] later`. +`U` replaces the running process with the new binary — same terminal tab, same tmux +pane, same arguments — so there is nothing to reopen; `X` folds that offer into +`keeptui [U] restart` for later in the session. On Windows there is no in-place restart: +`keeptui` prints `keeptui updated — run keeptui again` and exits. + +`keeptui` does not need to be in your own tracker for any of this — the check is built +in. If it *is* tracked, `update_cmd` from its `meta.yaml` entry governs the self-update +exactly as it governs `u` on that row, and the release data is shared with its card, so +once that card has been fetched the check costs nothing extra. Updating a tracked +`keeptui` with `u` on its row is the same thing as `U` — it offers the restart too. On a +working-copy build, where the whole feature is off, that row behaves like any other +tool's: the update runs and its log stays with the row, but there is no notice and no +restart offer — restarting a working copy would just bring back the binary you built. + +- One update at a time: while any update is running both `U` and `u` refuse out loud — + the bar says `another update is running` instead of starting a second one. (Without a + notice on screen, `U` is not a key at all: on a working-copy build it does nothing + whatsoever.) +- If the manager cannot be detected (a hand-downloaded binary), the bar says + `no known updater for keeptui — update manually` and nothing else happens. +- A Homebrew formula can lag behind the GitHub release: after updating, the installed + version may still be older than the latest tag, and the notice comes back. That is an + honest reflection of the state, not a bug. +- If the release check fails (no network, exhausted quota), there is simply no notice — + everything else works as before. Updating a tracked `keeptui` with `u` still works and + still offers the restart afterwards; there was just no notice to start from. +- If the update itself fails, the bar says `update failed — see [3]` and the reason stays + in panel `[3]`. The notice goes back to whatever it was before — `keeptui v0.5.0 + available` (where `U` retries), the folded cell if you had folded it, a pending + `[U] restart` from an earlier successful update, or nothing at all if there was no + notice to begin with. +- Quitting `keeptui` in the middle of a self-update is safe: the updater runs detached and + finishes on its own, exactly as for a tool update. +- The update targets the `keeptui` on your `PATH`. If you launched a copy by path + (`./keeptui`) while a different one is installed, that installed one is what gets + updated — and the restart brings back the copy you launched, so the notice reappears. + ## GitHub API and token `keeptui` fetches releases and repository cards through the GitHub REST API. Without a token the limit is **60 requests per hour** per IP, with a token — **5000**. Each tool with a `github` field costs 3 requests on startup, plus one more when you open its README in panel `[3]`; so a cold start with a large list and no token can hit the -limit — cards stay empty until the window resets. +limit — cards stay empty until the window resets. The `keeptui` release check adds one +more request per 24 hours (none on a `dev` build). -Quota usage is visible in the right corner of the status bar (`▮▮░░░░░░░░░░ 12/60`). The +Quota usage is visible in the right corner of the status bar (`▮▮░░░░░░░░░░ 12/60`). It +shares that corner with the folded self-update cell and yields to it: while the full +`keeptui … available` notice is up the gauge stays put, but after `X` the compact +`keeptui ↑ [U]` cell takes priority and can push the gauge off a narrow bar. The `L` key works from any panel (as long as no other input mode is active) and opens the API status overlay: token source, quota usage with an icon (`⚠` — low, `✕` — exhausted) and the reset time. Right in the overlay: @@ -235,7 +307,7 @@ atomic. | What | Where | |------|-------| | Tracker metadata | `~/.config/keeptui/meta.yaml` | -| Version and README cache (24h TTL) | `~/.config/keeptui/cache.json` | +| Version, README and self-check cache (24h TTL each) | `~/.config/keeptui/cache.json` | | GitHub token (`0600`) | `~/.config/keeptui/token` | | Session error log | `~/.config/keeptui/logs/keeptui-.log` | | Copy of the tracker before the one-tag migration | `~/.config/keeptui/meta.yaml.bak` | diff --git a/docs/plans/completed/20260725-self-update-restart.md b/docs/plans/completed/20260725-self-update-restart.md new file mode 100644 index 0000000..c63b0ca --- /dev/null +++ b/docs/plans/completed/20260725-self-update-restart.md @@ -0,0 +1,254 @@ +# Self-update: "new keeptui version" banner + update with restart + +## Overview + +keeptui starts watching its own releases and offers to update without leaving the TUI: + +1. **Built-in self-check** — one lightweight request at startup (release only, not a full `GetRepoData`) to `stanlyzoolo/keeptui`, cached for 24h. Works for any user out of the box, independent of `meta.yaml` — **including an untracked keeptui, which is the feature's main case**. On dev builds the feature is off entirely: no request, no UI — and that covers not only the `"dev"` ldflag default but any working-copy version (a Go pseudo-version from `go build .` on Go 1.24+, a `+dirty` suffix), see `selfCheckEnabled`. +2. **Non-modal banner in the hints bar** — `keeptui v0.5.0 available — [U] update [X] dismiss` in place of the usual hints; the whole app keeps working. `[X]` collapses the banner into a compact `keeptui ↑ [U]` cell next to the API gauge — the update stays reachable at any point in the session. Dismiss is session-only (never written to disk). +3. **Update through the existing pipeline** — `updater.Detect` (brew/go/…), `modeConfirmUpdate`, log streaming into `[3]`. What is new: the self-update log is visible regardless of the selection (keeptui may not be tracked), via a single predicate at every site bound to `updateLogFor`. +4. **Restart** — after a successful update the banner offers `[U] restart`: `tea.Quit` with a flag, then `syscall.Exec` of the new binary from `main` (unix); Windows degrades honestly (exit with a hint to start it again). + +## Context (from discovery) + +*Line numbers are as of `3176cb2` (main after PR #41, rust detection).* + +- **Own version:** `main.go` — `buildVersion()`/`resolveVersion` (ldflag → buildinfo → `"dev"`); the model does not know the version. `New(` appears in model tests en masse (100+) ⇒ do not change `New`'s signature, add `WithAppVersion` (an empty version = feature off = existing tests untouched). +- **Cache:** `internal/version/github.go:305` `CacheEntry`, `:775` `updateCacheEntry(repo, mutate)`. **Trap:** the card's freshness gate in `getRepoData` is `CheckedAt`-only with no content check; a release-only pass that stamped `CheckedAt` would poison a tracked keeptui's card (blank for 24h). The precedent is the separate `ReadmeCheckedAt` ⇒ add `ReleaseCheckedAt` symmetrically. `fetchRelease` returns `errNoReleases` on a 404 — `getRepoData` treats it as conclusive, so do the same. +- **Fetch:** `fetchRelease(repo)` (`github.go`) returns `ReleaseInfo{Tag, HtmlUrl, PublishedAt, Body}`; `testAPIBase` (`github.go:281`) is the httptest seam and is **not exported from the package** — model tests cannot reach the network and must not execute the self-check command. `IsNewer` is at `internal/version/detect.go:184`. +- **The update pipeline and its guards (the full list of sites bound to `updateLogFor`/`updatingFor`/`selectedMeta`):** + - `detectUpdateCmd` (`commands.go:456`) → `updateDetectedMsg`, handler at `model.go:658` — compound guard `!ok || mt.Name != msg.tool || m.updatingFor != ""` (`:665`); + - `updateConfirmUpdate` (`mode.go:410`) — **opens with a `selectedMeta` guard** (`:414-415`): with an empty tracker enter cancels silently; the confirm bar is the `modeConfirmUpdate` branch in `renderStatusBar` (`render.go:119-130`) and takes its name from `selectedMeta()`; + - `updateChunkMsg` (`model.go:698`) — repaint/autoscroll gate `mt.Name == m.updateLogFor`; + - `updateDoneMsg` (`model.go:705`) — **early return on `toolByName(msg.tool) → !ok`** (`:713`) ahead of every branch below; failure log seed + repaint gate (`:726-730`); + - the log branch of `renderHelpContent` (`render.go:1420`) and the `[3] Update` title (`:931`); + - the `setHelpContent` gate (`model.go:1603`) — `updateLogFor != mt.Name`; + - the `autoFetchCmdsForSelected` gate (`commands.go:399`); + - the card spinner (`render.go:1045`) — `updatingFor == t.Name`; an untracked keeptui has no card. +- **Bar:** `renderStatusBar` (`render.go:45`), `renderHintsBar` (`render.go:218`) — cells are most-important-first and drop from the right; **exactly one** right-aligned element (the gauge) with a fixed full → compact → hidden degradation. Invariants: `TestStatusBarNeverWraps` (`render_test.go:3594`; its cases run with an **unknown** rate, so the gauge is not drawn), `TestRenderHotkeysSizeBudget` (`render_test.go:3341`). The `[?]` overlay: column 1 (`Global` 5 rows + `[1] Tools` 8) makes the framed height **exactly 20** — a sixth row in `Global` breaks the budget by height, not width; column 2 (`[2] Brief`, 10 rows) is the shortest with ~5 rows of slack, and its widest description is 12 cells (`cycle status`). +- **Init ordering:** `TestInitFetchesReadmeForSelected` (`commands_test.go:503`) asserts that the **last** batch element is the readme command and executes it ⇒ put the self-check near the start (next to `fetchRateCmd`), and write the new Init test against the batch **length** (following `TestInitHelpProbeFollowsHelpMode`) without executing the command. +- **Restart infrastructure:** `runTUI` discards the model `p.Run()` returns — a type assert plus a flag is needed. **Linux trap:** `os.Executable()` is `/proc/self/exe`, symlink-**resolved** — after a keg-style upgrade the old path may still exist and exec would launch the old binary (an endless "restart → same banner" loop); on macOS the path matches argv (the `/opt/homebrew/bin/keeptui` symlink points at the new version after an upgrade). +- **`internal/updater`, `internal/launcher`, `internal/proc` are unchanged by this plan.** Note: PR #41 added a brew-by-name fallback to `updater.Detect` (a `LookPath` miss plus an existing `Cellar/`/`Caskroom/` → `brew upgrade `), so `ErrUnknownManager` for keeptui is now markedly less likely — the `no known updater…` branch in this plan is a rare fallback (a hand-downloaded binary), not the typical outcome. + +## Development Approach + +- **testing approach:** Regular (implementation, then tests in the same task) — as in previous plans, matching the packages' table-driven style. +- Finish each task before starting the next; small focused changes. +- **CRITICAL: every task includes new/updated tests** (success plus errors/edge cases). +- **CRITICAL: all tests green before the next task** (`go test -race ./...`). +- **CRITICAL: update this file whenever the scope changes.** +- Backward compatibility: `cache.json` gains a new optional field (`omitzero`), `meta.yaml` does not change. + +## Testing Strategy + +- **unit tests:** required in every task. + - `version`: httptest via `testAPIBase` + cache isolation already in `TestMain` (`SetConfigDirForTesting`). + - `model`: table-driven over `Update()` (msgs/keys), direct assertions on rendered strings; bar cases involving the gauge seed `m.rate` with `Known: true` (otherwise the gauge is not drawn and the geometry goes unchecked). + - `main`: the pure path-resolution core as a table test; `syscall.Exec` itself is a thin wrapper and is not tested (like `runTUI`); the hint string is a shared const and is asserted. +- **e2e:** none in this project (only `demo/*.tape`, out of scope). Manual smoke lives in Post-Completion. + +## Progress Tracking + +- `[x]` right after finishing; ➕ for new tasks; ⚠️ for blockers; keep the plan in sync with reality. + +## Solution Overview + +- **`version`:** exported `SelfRepo = "stanlyzoolo/keeptui"` and `SelfLatest() (string, error)` — a release-only pass with its own freshness gate (a fresh `ReleaseCheckedAt` **or** a fresh `CheckedAt` plus an entry that carries an answer); the write goes through `updateCacheEntry`, mutating `Latest`/`Body`/`HtmlUrl`/`PublishedAt` and stamping **only** `ReleaseCheckedAt`. `errNoReleases` is conclusive (it stamps `ReleaseCheckedAt`, as in `getRepoData`) so a release-less repo is not re-probed on every launch, but the negative itself lives in the `ReleaseMissing` flag and the card's release tuple is **not wiped** (see Technical Details). A tracked keeptui shares the cache entry: a fresh full pass makes the self-check free, and conversely the self-check never "refreshes" the card. +- **`model`:** a `WithAppVersion(v)` builder; const `selfToolName = "keeptui"`; state `selfState` (enum `selfNone/selfOffered/selfDismissed/selfUpdated/selfUpdatedLater` — with **no** "updating" member: "a self-update is running" is derived by the predicate `selfUpdating()` = `isSelfUpdate(updatingFor)`, so there are never two sources of truth); `selfCheckCmd` from `Init()` (gated on `selfCheckEnabled()`: the version is neither `""` nor `"dev"` **and not** a working copy — a Go pseudo-version or `+dirty`); the `U`/`X` keys in `modeNormal`; confirm/streaming reuse driven by the target's name (`updateTarget`) rather than a separate self flag on the plan; the `[3]` override through the single predicate `showsUpdateLog()` at **every** site; a restart flag plus `RestartRequested()`. +- **`main`:** `model.New(meta).WithAppVersion(buildVersion())`; after `p.Run()` a type assert, and on the flag `restartSelf()`: pure path resolution (argv0 semantics, `LookPath` first for a bare name) plus `syscall.Exec` (unix) / exit with a hint (windows). + +## Technical Details + +- **Self-check freshness:** a hit is `selfCachedTag(e) (string, bool)`: a fresh `ReleaseCheckedAt` answers on its own (only `SelfLatest` writes it, and only conclusively), or a fresh `CheckedAt` plus an entry where the full pass left something (a non-empty `Latest` **or** a recorded `ReleaseMissing` negative). A failed fetch stamps nothing (the same poison guard as the README), **except `errNoReleases`** — that one is conclusive: stamp `ReleaseCheckedAt` and answer "no release" (the feature stays quietly off until the TTL expires). `ReleaseCheckedAt time.Time \`json:"release_checked_at,omitzero"\`` with a comment mirroring `ReadmeCheckedAt`. +- **A 404 is stored as a flag, never by wiping the tuple** (`CacheEntry.ReleaseMissing bool \`json:"release_missing,omitempty"\``). The release tuple (`Latest`/`Body`/`HtmlUrl`/`PublishedAt`) belongs to the **shared** card: `getRepoData` writes it only `if relErr == nil` (its own 404 preserves the previous values), and `getChangelog` returns the cached `Body` on any error — "known content wins". So on `errNoReleases` `SelfLatest` writes **only** the stamp plus `ReleaseMissing = true`: one 404 window (the only release converted to a draft before being re-cut; a repo the token cannot see — GitHub answers 404, not 403) would otherwise strip a **tracked** keeptui's `latest:` line, release date, `↑` marker, changelog body and the card's clickable release URL, and it would do so without advancing `CheckedAt`, i.e. with no way to recover. The flag is maintained by **all three** release writers (`SelfLatest`, `getRepoData`, `getChangelog`: set on a 404, cleared whenever a release is actually fetched) and read only by the self-check, through `selfTagOf(e)` ("which release to offer in the banner": nothing when `ReleaseMissing`, the cached tag otherwise). `getChangelog`'s write is not decorative: with a fresh `CheckedAt` (the full pass short-circuits) and an empty `Body` (a release published with no notes) it is the **only** pass that reaches `/releases/latest` at all, so without that write nobody would notice a deleted or drafted release and `selfCachedTag`'s `CheckedAt` branch would keep offering the stored tag for the rest of the TTL. The banner stays quiet on a 404 either way, and the card loses nothing. +- **`selfCheckCmd()` (no parameters) → `selfCheckMsg{latest string, err error}`:** the command **logs nothing at all** (like `remoteCmd`: `version` already records the transport failure in `doGH` and the bad status in `classifyStatus`, with details this layer does not have; a second line per offline launch would only dilute the "a log file means something broke" signal. "No release" is not a failure at all: it arrives as an empty tag with `err == nil`. See the `[deviation, review]` in Task 2). The comparison lives in the handler: an error or an empty tag leaves the state at `selfNone`; on success `version.IsNewer(appVersion, latest)` → `selfOffered` + `selfLatest`, otherwise the state is **not written at all** (see the `[decision]` in Task 2: the handler writes only from `selfNone`, so a late or repeat answer cannot roll back `selfDismissed`/`selfUpdated`/`selfUpdatedLater`). The comparison is against **`appVersion`** (ldflag/buildinfo — the version of the *running* binary), not `m.versions[selfToolName].Installed`: since PR #41 `InstalledVersion` has three fallbacks (`--version` → brew directory → `cargo install --list`), any of which may report a version that does not match the running process. In `Init()` the command is batched **next to `fetchRateCmd` at the start**, not last (see the readme-test note in Context). +- **The `selfState` machine** (plus the derived `selfUpdating()`): + - `selfOffered` — a two-cell banner: `keeptui available — [U] update` (info and the main action fused into one cell so that a right-side drop cannot take the `U` key away from its text) and `[X] dismiss`; `U` → detect, `X` → `selfDismissed`. + - `selfDismissed` — the usual hints plus a compact `keeptui ↑ [U]` cell in the right group next to the gauge; `U` → detect. + - **during a self-update** (`selfUpdating()` true) — `selfState` does not change; neither the banner nor the compact cell is drawn, and in their place sits a compact right-hand `keeptui updating…` cell (the only in-flight indicator besides the log in `[3]` — an untracked keeptui has no card, so the `render.go:1045` spinner never fires). + - an update failure → `selfState` is **not written at all** + statusMsg `update failed — see [3]` + `logx.Errorf`. The banner comes back on its own as soon as `updatingFor` clears: the offer (where `U` is the retry), the collapsed cell (if `[X]` was pressed mid-update), a pending `[U] restart` from an earlier successful update — or nothing, if there was no banner to begin with. Any write here could only roll one of those states backwards (see the `[deviation]` in Task 4). + - `selfUpdated` — the banner `keeptui updated — [U] restart` + `[X] later`; `U` → restart, `X` → `selfUpdatedLater`. + - `selfUpdatedLater` — a compact `keeptui [U] restart` cell; `U` → restart. +- **`renderHintsBar`'s right group — an explicit degradation order** (today there is one right-hand element, the gauge; it becomes two): full gauge + self cell → compact gauge + self cell → self cell without the gauge → gauge without the self cell → hints only. The self cell outranks the gauge (it is actionable). The cell's width participates in the same `gap` arithmetic as the gauge. +- **Keys:** `case "U"`/`case "X"` in the normal-mode branch of `Update()` (structurally unreachable inside inputs, like `L`). The order of the checks in `U` is load-bearing: **first** `selfState == selfNone` → a full no-op (the key is unbound; on a dev build this is the only reachable state, and there the feature does not exist), and **only then** the one-update-at-a-time guard — with a banner up, `updatingFor != ""` yields the `updateBusyStatus` statusMsg (`another update is running`), identically for `U` and for `[u]`. +- **Self-detect:** the helper `selfTool()` returns the `selfToolName` entry from `m.meta` when tracked (inheriting `update_cmd`), otherwise `loader.Tool{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo}` (the host-prefixed form all tracked tools use). `updateDetectedMsg` gains a `self bool` field, filled by `detectUpdateCmd(t, self)` **itself** (one constructor for both paths, no wrapper). The handler (`model.go:658`) delegates relevance to the shared predicate `acceptsUpdateDetect(msg)`: **both** paths refuse while `updatingFor != ""` and while `m.mode != modeNormal` (detection spawns subprocesses and the result can land seconds later in the middle of a note edit or a search; opening a confirm under an input steals the keystroke — mirroring `launchDoneMsg`'s mode gate), and on top of that the tool path requires a selection match while the self path does not (it may have no selection at all). A result that fails the gate is dropped silently and the state stays `selfOffered` (`U` retries). `ErrUnknownManager` → statusMsg `no known updater for keeptui — update manually` (state stays `selfOffered`) — after PR #41's brew-by-name fallback this is a rare case (a hand-downloaded binary). Success → `m.updatePlan` + **`m.updateTarget = msg.tool`** + `modeConfirmUpdate`. +- **Confirm:** in `updateConfirmUpdate` (`mode.go:410`) the `selectedMeta` guard (`:414-415`) **goes away entirely** — the dialog's target is already named in `m.updateTarget`, so one path serves both a tool and keeptui (otherwise an empty tracker would make enter cancel a self-confirm silently, and a selection that moved during detection would retarget a tool's plan onto another row). Enter → `updatingFor = updateLogFor = target`, log reset, `m.selfUpdateLog = m.selfUpdating()`, `startUpdateCmd(plan, target)`; esc or any other key cancels and clears `updateTarget`. The **confirm bar** — the `modeConfirmUpdate` branch in `renderStatusBar` (`render.go:119-130`) — renders `update : ` with no separate self branch (a name from `selectedMeta()` would show a foreign tool or an empty name). +- **The `[3]` override — a single predicate** `showsUpdateLog()` (no parameter; it resolves the selection itself: `selfUpdateLog || (updateLogFor != "" && selected.Name == updateLogFor)`), substituted at **every** site: the `[3] Update` title (`render.go:931`), the log branch of `renderHelpContent` (`:1420`, including the branch with no `selectedMeta`), repaint/autoscroll in `updateChunkMsg` (`model.go:698`), the failure seed + repaint in `updateDoneMsg` (`:726-730`), the `setHelpContent` gate (`:1603` — otherwise a live self-log lets `helpEntries` be computed for the selected tool and `j`/`k` drive the spotlight through the log), and the `autoFetchCmdsForSelected` gate (`commands.go:399`). `selfUpdateLog` is released in `switchHelpMode`/`selectMeta`, and only once the update has finished. +- **`updateDoneMsg` under self:** the self branch runs **ahead of** the `toolByName` early return (`model.go:713`, handler from `:705`) — for an untracked keeptui `toolByName` answers `!ok`, so a branch after that guard would never run (the update would finish silently and `[U] restart` would be unreachable). The discriminator is **`m.isSelfUpdate(msg.tool)`** (`name == keeptui && selfCheckEnabled()`): an update of keeptui is a self-update whichever key started it (`[u]` on the tracked row gets the restart offer too), but only on a build where the feature exists at all; on a dev build the same key takes the ordinary tool path. Success → `selfState = selfUpdated` + statusMsg `updated keeptui`; `fetchInstalledCmd(t)` only when keeptui is tracked (`ok`). Failure → statusMsg + `logx.Errorf` (through the shared `recordUpdateFailure`) and **no** `selfState` write. `updatingFor` is cleared unconditionally in both outcomes. +- **Restart:** `U` under `selfUpdated`/`selfUpdatedLater` → `m.restartRequested = true` + `tea.Quit`; the exported `RestartRequested() bool`. In `main.go`: `final, err := p.Run()` → `if m, ok := final.(model.Model); ok && m.RestartRequested() { restartSelf() }` — strictly after `p.Run()` returns (Bubble Tea has already restored the terminal). +- **Path resolution** (pure core plus wrapper, both in `restart_unix.go` — see the `[deviation]` in Task 5): `resolveSelfPath(executable string, exists func(string) bool, lookPath func(string) (string, error), argv0 string) (string, error)` — **argv0 semantics mirroring the shell**: an argv0 carrying a path separator wins when `exists`; a bare argv0 goes through **`lookPath(argv0)` first**, and that hit is accepted only when `sameProgram(hit, executable)` (the same base program) — a foreign binary of the same name earlier in PATH must not hijack the restart. The fallback in both cases is `executable`, **also behind an `exists` check**; if that misses too it is an error (outward: print `restartHint`, never call exec). The order is load-bearing: on Linux `os.Executable()` (= `/proc/self/exe`) is symlink-resolved and after an upgrade may point at a live *old* keg path, so exec'ing it would loop "restart → same banner"; `LookPath` finds the current binary on PATH. +- **Exec:** `restart_unix.go` (`//go:build !windows`): `restartSelf()` — `selfPath()` + `syscall.Exec(path, os.Args, os.Environ())`; a resolve or exec failure prints the hint (the shared const `restartHint = "keeptui updated — run keeptui again"`) and exits normally. `restart_windows.go`: straight to the hint and exit (no spawn+exit). +- **The `[?]` overlay:** a new `Self` group in **column 2** (the shortest, with ~5 rows of slack: header + 2 rows + a blank = 4), state-dependent: absent at `selfNone`, `U — self-update`/`X — dismiss` on the offer or the collapsed offer, `U — restart`/`X — later` after an update; keep the descriptions ≤ 12 cells (column 2's current maximum is `cycle status`), or the column grows wider and the overlay passes 76. Do not touch column 1 — it sits exactly on the height budget. `TestRenderHotkeysSizeBudget` is the arbiter. +- **Not touched:** `internal/updater`, `internal/launcher`, `internal/proc`, the `meta.yaml` schema, and the statusMsg > banner > hints branch order (a transient statusMsg with a 1s TTL covers the banner and clears itself). + +## What Goes Where + +- **Implementation Steps** (`[ ]`): code in `internal/version`, `internal/model`, `main.go` plus tests and documentation. +- **Post-Completion** (no checkboxes): the manual restart smoke on a live terminal, brew-formula observations, demo-gifs. + +## Implementation Steps + +### Task 1: `version.SelfLatest` — a release-only pass with its own freshness + +**Files:** +- Modify: `internal/version/github.go` +- Create: `internal/version/selfcheck.go` +- Create: `internal/version/selfcheck_test.go` + +- [x] `github.go`: add the `ReleaseCheckedAt time.Time` field to `CacheEntry` (`release_checked_at,omitzero`) with a comment mirroring `ReadmeCheckedAt` (why not the shared `CheckedAt`: a release-only pass must not refresh the card) +- [x] `selfcheck.go`: `const SelfRepo = "stanlyzoolo/keeptui"`; `SelfLatest() (string, error)` — a cache hit through `selfCachedTag` (a fresh `ReleaseCheckedAt` **or** a fresh `CheckedAt` plus the answer it left behind); otherwise `fetchRelease(SelfRepo)` + `updateCacheEntry` (`e := existing`; `Latest`/`Body`/`HtmlUrl`/`PublishedAt`; stamping **only** `ReleaseCheckedAt`); `errNoReleases` is conclusive: it stamps `ReleaseCheckedAt` + `ReleaseMissing` and answers "no release" outward; every other failure stamps nothing + - **[deviation]** the gate: `Latest != ""` applies **only** to the `CheckedAt` branch (`selfCachedTag`). The plan's literal formula (`(A||B) && Latest != ""`) contradicts its own test requirement "404 → the second call makes no request": with no releases `Latest` is empty and the entry would always read as a miss. `ReleaseCheckedAt` is written by `SelfLatest` alone and only conclusively, so a fresh stamp already *is* the answer (a tag, or "no release" when `ReleaseMissing`); `CheckedAt` (the full pass, whose gate has no content check) carries no such guarantee, so there the "the full pass left something" check remains: a non-empty `Latest` **or** the negative it recorded. + - **[deviation, review]** `errNoReleases` **does not wipe** the release tuple — a `ReleaseMissing` flag is written instead (a new `CacheEntry` field, `release_missing,omitempty`), and `selfTagOf(e)` forms the answer. The first review iteration decided the opposite (the "deleted" release's tag was cleared, `TestSelfLatestDroppedReleaseClearsTag`), and that turned out to be the only place in the package where one pass **destroys** another feature's content: on the same 404 `getRepoData` writes the tuple only `if relErr == nil` (i.e. preserves it) and `getChangelog` returns the cached `Body` on any error. One 404 window (a release drafted before being re-cut; a repo invisible to the token — GitHub answers 404) would strip a **tracked** keeptui's `latest:`, date, `↑`, changelog body and clickable release URL, and `CheckedAt` deliberately does not move, so the changelog's fallback would have nothing left to fall back to. Both behaviors are defensible in isolation, but two passes must not disagree about one 404 — and the destructive one was the side pass. The flag is maintained by all three release writers (set on a 404, cleared on a fetched release) and read only by the self-check; the banner still stays quiet and the card loses nothing. The test was rewritten as `TestSelfLatestDroppedReleaseKeepsSharedTuple`, with `TestSelfLatestNegativeSharedWithFullPass` and `TestSelfLatestNegativeClearedByChangelogFetch` added. + - **[deviation, review]** the sixth iteration closed a hole in that same contract: `getChangelog` only ever **cleared** the flag (on success) and on its own 404 returned through the "serve the cached tuple" branch before any `updateCacheEntry` — so "all three writers maintain it" was only half true. The hole is narrow but real: a fresh `CheckedAt` plus an empty `Body` is the one window where only the changelog reaches `/releases/latest`, and a deleted release went unnoticed for the whole TTL. Now `errNoReleases` writes `ReleaseMissing = true` (the tuple is still preserved and `CheckedAt` is not re-stamped), pinned by `TestSelfLatestNegativeRecordedByChangelog404`. + - **[decision]** "no release" surfaces as `("", nil)` rather than a separate exported error: `errNoReleases` stays private and Task 2's `selfCheckMsg` handler gets its "skip logging" for free (err == nil). An empty tag is unambiguous — a cache hit requires either an answer from the release stamp or one left by the full pass. + - **[decision]** a transient failure returns `("", err)` with no stale fallback onto the cached `Latest` (unlike the README): a "version available" banner from an expired window is a call to action, not known content on display. +- [x] write the tests (httptest + `testAPIBase`): a cold fetch → the tag plus a stamped `ReleaseCheckedAt` with `CheckedAt` still **zero**; a repeat call within the TTL → no request (server hit counter); a fresh `CheckedAt` from a full pass → no request; a 404 (`errNoReleases`) → `ReleaseCheckedAt` stamped, the repeat call making no request +- [x] write the error tests: 403/remaining=0 → `ErrRateLimited` outward, nothing stamped; 500 → an error, nothing stamped; an existing entry holding a README/card is not clobbered by the mutation (merge-on-write) +- [x] `go test -race ./internal/version/...` — green before Task 2 + +### Task 2: Model state — `WithAppVersion`, `selfCheckCmd`, the `selfState` machine + +**Files:** +- Modify: `internal/model/model.go` +- Modify: `internal/model/commands.go` +- Modify: `main.go` +- Modify: `internal/model/model_test.go` (or a neighbouring `*_test.go` as appropriate) + +- [x] `model.go`: const `selfToolName = "keeptui"`; fields `appVersion string`, `selfLatest string`, `selfState` (enum `selfNone/selfOffered/selfDismissed/selfUpdated/selfUpdatedLater`), `selfUpdateLog bool`, `updateTarget string`, `restartRequested bool`; the predicate `selfUpdating() bool` (derived, `isSelfUpdate(updatingFor)` — not a separate enum member, so two sources of truth cannot drift apart); the builder `WithAppVersion(v string) Model`; the accessor `RestartRequested() bool` + - **[deviation]** the "self plan" field is **deferred to Task 4**: in Task 2 it has neither reader nor writer and `golangci-lint` (`unused`) fails the build on it. The other fields pass: `selfUpdating()` reads `selfUpdateLog`, `RestartRequested()` reads `restartRequested`. Task 4 declares it together with its first write (`updateDetectedMsg`) and read (the confirm bar). + - **[deviation, review]** the planned `updatePlanSelf bool` never materialised: review collapsed "the plan's self flag" and "the target's name" into the single field **`updateTarget string`**, written by the detect handler (`msg.tool`) and read by the confirm and the bar. Two fields were two sources of truth about one thing, and the target's name additionally removes `selectedMeta()` from the confirm path entirely. + - **[deviation, review]** `selfUpdating()` is derived not from `updatingFor == selfToolName && selfUpdateLog` (the plan) but from `isSelfUpdate(updatingFor)` = `name == keeptui && selfCheckEnabled()`. `selfUpdateLog` left the condition: it is a *consequence* ("the log belongs to a self-update"), and keeping it in the predicate would make the predicate depend on who set the flag first. The version gate, conversely, is mandatory — without it `[u]` on a tracked `keeptui` row would switch the whole self feature on for a dev build. + - **[decision]** the version gate was extracted into the predicate `selfCheckEnabled()` (rather than an inline `Init` condition): Task 3's renderer needs the same condition, and duplicating it in two places is how they drift. Review added `isDevVersion` to it (Go pseudo-version + `+dirty`): since Go 1.24 `go build .` stamps a version from VCS, and without that check a developer's own build would show the banner and offer to overwrite itself with a release. +- [x] `commands.go`: `selfCheckCmd() tea.Cmd` (`safeCmd`, no parameters) → `version.SelfLatest()` → `selfCheckMsg{latest, err}`; log only unexpected failures (skipping `ErrRateLimited` and "no release", following `readmeCmd`) + - **[deviation, review]** the command logs **nothing at all** (like `remoteCmd`): `version` already records both the transport failure (`doGH`) and the bad status (`classifyStatus`) with details this layer does not have, so a second line per offline launch would only dilute the "a log file means something broke" signal. +- [x] `model.go` `Init()`: batch `selfCheckCmd()` **next to `fetchRateCmd` at the start of the batch** (not last — `TestInitFetchesReadmeForSelected` executes the last element) and only when `selfCheckEnabled()`; the `selfCheckMsg` handler: err → stay at `selfNone`; `IsNewer(appVersion, latest)` → `selfOffered` + `selfLatest`, otherwise `selfNone` (comparing against `appVersion`, not `m.versions` — see Technical Details) + - **[decision]** the "not newer" branch **does not write** `selfState = selfNone` but leaves the state alone: when the message arrives it is already `selfNone`, and an unconditional reset is the hole through which a late or repeat `selfCheckMsg` would roll back a state the user has already acted on (`selfUpdated`). Review tightened this to "the handler writes **only from `selfNone`**". Pinned by `TestSelfCheckMsgKeepsActedOnState`. +- [x] `main.go`: `model.New(meta).WithAppVersion(buildVersion())` (in `runTUI`, where `ver` is already computed) + - **[deviation, review]** the call moved into the named `newRootModel(meta, ver)` — see the shared `[deviation, review]` in Task 5 about making both wiring lines testable. +- [x] write the tests: `Init` with no version and with `"dev"` — the batch length without the self-check, with `"v0.4.2"` — one more (assert on **length**, do not execute the command — live API, and the model package has no seam; follow `TestInitHelpProbeFollowsHelpMode`); `selfCheckMsg` — newer → `selfOffered`, equal/older/non-semver → `selfNone`, err → `selfNone` + - tests live in the new `internal/model/selfupdate_test.go` (the package has no `model_test.go`; this file becomes home to the Task 3-5 tests too) +- [x] `go build ./... && go test -race ./internal/model/...` — green before Task 3 (plus `go vet ./...`, `golangci-lint run`, and the full `go test -race ./...`) + +### Task 3: The hints-bar banner + the `U`/`X` keys + the `[?]` overlay + +**Files:** +- Modify: `internal/model/render.go` +- Modify: `internal/model/model.go` +- Modify: `internal/model/render_test.go` +- Modify: `internal/model/model_test.go` (keys) + +- [x] `render.go` `renderStatusBar`: in the three normal focus branches, replace the hint cells at `selfOffered` with a two-cell banner — `keeptui available — [U] update` (info and action fused so `U` survives a right-side drop; the version in `UpdateAvailableStyle`) and `[X] dismiss`; at `selfUpdated` — `keeptui updated — [U] restart` and `[X] later`; draw no banner while `selfUpdating()` + - **[decision]** the banner is checked **once**, right after the `statusMsg` branch, rather than duplicated across the three focus branches: there are no other paths below it (all three focuses are the function's last branches), so one point covers all three and cannot drift. The `statusMsg > banner > hints` order is preserved. + - **[decision]** rendering is **not** gated on `selfCheckEnabled()`: the single source of truth is `selfState`, and it leaves `selfNone` only through `selfCheckMsg`, which only the gated command produces. A second gate would introduce a "state exists but no banner" case — a silently invisible banner. +- [x] `render.go` `renderHintsBar` (`:218`): a second right-hand cell next to the gauge — `keeptui ↑ [U]` at `selfDismissed`, `keeptui [U] restart` at `selfUpdatedLater`, `keeptui updating…` while `selfUpdating()`; **degradation order**: full gauge + cell → compact gauge + cell → cell without the gauge → gauge without the cell → hints only; the cell's width participates in the same `gap` arithmetic + - **[note]** at 80 columns in `focusTools` the hints (79 cells, 69 after `[?] keys` drops) leave room for neither the cell (84 needed) nor the compact gauge (83), so the right group disappears entirely — exactly as the gauge already did there before the feature. The `U` key still works; it is documented by the new `U — self-update` row in the `[?]` overlay. +- [x] `model.go` `Update()` normal-mode: `case "U"` — `selfOffered`/`selfDismissed` → self-detect (Task 4; a statusMsg stub is acceptable within this task), `selfUpdated`/`selfUpdatedLater` → restart (Task 5, likewise), a no-op while `updatingFor != ""`; `case "X"` — `selfOffered` → `selfDismissed`, `selfUpdated` → `selfUpdatedLater` + - **[decision]** both `U` branches are `setStatus` stubs (`detecting how keeptui was installed…` / `restart keeptui to run the new version`), to be replaced by Tasks 4/5. The tests assert only the durable parts: `U` does not change `selfState` in any of the four states (still true after Tasks 4/5 — neither the confirm nor the restart moves the state), a command is returned, and the `updatingFor` guard is silent in both directions. + - **[deviation, review]** the `updatingFor` guard stopped being silent (a shared `updateBusyStatus` for `U` and `[u]`: a blocked action must report itself, like every other one here) and moved **below** the `selfState == selfNone` check. The order is not cosmetic: at `selfNone` the key is unbound, and `selfNone` is the only state a dev build can reach, so putting the guard first made `U` answer `another update is running` during any ordinary tool update on a working copy — announcing a self feature that exists neither in the request nor in the UI. Pinned by `TestSelfUpdateKeyInertWithoutBanner` (the dev and pseudo-version rows fail if the checks are swapped). +- [x] `render.go` `renderHotkeys`: a new `Self` group in **column 2** (~5 rows of slack; leave column 1 alone — it sits exactly on the height budget): `U — self-update`, `X — dismiss`; descriptions ≤ 12 cells (column 2's maximum is `cycle status`), or the overlay passes 76 columns + - the overlay's actual size after the change is 20×75 (height exactly on budget, width unchanged: `self-update` is 11 cells against `cycle status`'s 12) + - **[deviation, review]** the group became **state-dependent**: absent at `selfNone` (both keys are unbound there — and on any dev build that is the only state, so a fixed list would advertise two dead keys), `U — self-update`/`X — dismiss` at `selfOffered`/`selfDismissed`, and `U — restart`/`X — later` after an update — exactly what the keys do in each. `TestRenderHotkeysSizeBudget` checks the budget in **all five** states. +- [x] write the tests: the bar rendered in every self state (full banner / compact / updating / none); extend `TestStatusBarNeverWraps` to `selfOffered`/`selfUpdated` **and** `selfDismissed`/`selfUpdatedLater` at 80×24 — with `m.rate.Known = true`, otherwise the gauge is not drawn and the right group's geometry goes unchecked; `U`/`X` change state only in `modeNormal` (in `modeSearch` they stay query text); `U` is a no-op while `updatingFor` is set; amend `TestRenderHotkeysSizeBudget` + - plus: the banner in all three focuses, `statusMsg` covering the banner, the right group's degradation (160 → gauge+cell, 100 → cell without the gauge) and a regression test that the gauge's own degradation is untouched without a cell +- [x] `go test -race ./internal/model/...` — green before Task 4 (plus `go build ./... && go vet ./... && go test -race ./...`, `golangci-lint run` — 0 issues) + +### Task 4: The self-update pipeline — detect, confirm, streaming, the `[3]` override + +**Files:** +- Modify: `internal/model/model.go` +- Modify: `internal/model/mode.go` +- Modify: `internal/model/commands.go` +- Modify: `internal/model/render.go` +- Modify: `internal/model/model_test.go`, `internal/model/render_test.go` + +- [x] `model.go`: **declare the plan's target field** (deferred from Task 2, where `unused` rejected it); the helper `selfTool() loader.Tool` (the `selfToolName` entry from `m.meta` when tracked — inheriting `update_cmd`; otherwise `{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo}`); `updateDetectedMsg` + a `self bool` field; `commands.go`: `detectUpdateCmd(t, self)` — one constructor for both paths; wire Task 3's `case "U"` to `detectUpdateCmd(selfTool(), true)` + - **[decision]** `selfTool()` takes the tracked entry through `toolByName` (`m.tools`) rather than scanning `m.meta`: `m.tools` is the same `ToolsFromMeta` projection that feeds `[u]` and is rebuilt on every tracker mutation, so self-detect and tool-detect for one entry are guaranteed the same `loader.Tool` (including `update_cmd`). + - **[deviation, review]** instead of a `detectSelfUpdateCmd(t)` wrapper, the self flag became a **parameter** of `detectUpdateCmd(t, self bool)`: the wrapper was a line-for-line copy of the body with one message field changed. Also, the synthesized entry's `GitHub` is host-prefixed (`github.com/owner/repo`, the form every tracked tool uses), or the value would be a trap for the next reader. +- [x] `model.go` `updateDetectedMsg` handler (`:658`): the compound guard at `:665` is replaced by the shared predicate `acceptsUpdateDetect(msg)` — **both** paths require `m.mode == modeNormal && m.updatingFor == ""` (a late detect result must not open a confirm under an input — mirroring `launchDoneMsg`'s mode gate; failing the gate drops it silently and the state stays `selfOffered`), and the tool path additionally requires a selection match; `ErrUnknownManager` → statusMsg `no known updater for keeptui — update manually` (state stays `selfOffered`); success → `updatePlan` + `updateTarget = msg.tool` + `modeConfirmUpdate` + - **[decision]** the guard was extracted into the predicate `acceptsUpdateDetect(msg)` rather than forking the branch bodies — success writes `updateTarget = msg.tool`, and the single site that opens the dialog always overwrites the target, so a tool confirm can never inherit a self plan. + - **[deviation, review]** `mode == modeNormal` was applied to **both** paths, not just self: the argument ("opening a confirm under an input steals the keystroke") applies to tool detection identically, and the asymmetry would only have invited drift. +- [x] `mode.go` `updateConfirmUpdate` (`:410`): the `selectedMeta` guard (`:414-415`) is removed entirely — the target is already named in `updateTarget` (with an empty tracker enter would cancel a self-confirm silently); enter → `updatingFor = updateLogFor = target`, log reset, `selfUpdateLog = m.selfUpdating()`, `startUpdateCmd(plan, target)`; esc or any other key cancels and clears `updateTarget` + - **[decision]** the tool branch of enter **releases** `selfUpdateLog`: there is one log buffer, and starting a tool update claims it — a leftover self override would draw the tool's log under every row of the list. Pinned by `TestToolConfirmEnterReleasesSelfLog`. + - **[deviation, review]** the self/tool fork in the confirm disappeared along with the guard: there is one path, and `selfUpdateLog` is derived from `m.selfUpdating()` (i.e. from the `updatingFor` just assigned, through the version gate) rather than from `target == selfToolName` — otherwise on a dev build a keeptui update would claim panel `[3]` from every row of the list. +- [x] `render.go` the `modeConfirmUpdate` branch in `renderStatusBar` (`:119-130`): `update : ` rather than the name from `selectedMeta()` (which with a self plan would show a foreign tool or an empty name) — there is no self branch in the bar at all +- [x] `model.go`/`render.go`/`commands.go`: the single predicate `showsUpdateLog()` (`selfUpdateLog || (updateLogFor != "" && selected.Name == updateLogFor)`) at **every** site: `render.go:931` (the title), `:1420` (the log branch, including the branch with no selection), `model.go:698` (`updateChunkMsg` repaint/autoscroll), `:726-730` (`updateDoneMsg` failure seed + repaint), `:1603` (`setHelpContent` — otherwise the `j`/`k` spotlight runs through the log), `commands.go:399` (`autoFetchCmdsForSelected`); `selfUpdateLog` is released in `switchHelpMode`/`selectMeta` only once the update has finished + - **[deviation]** the predicate takes **no parameter** — `showsUpdateLog()` resolves the selection itself (`selfUpdateLog || (updateLogFor != "" && selected.Name == updateLogFor)`). The plan sketched `showsUpdateLog(mt)` plus a separate "empty list variant"; two forms of one rule is exactly the drift the predicate exists to prevent, and the `renderHelpContent` site must answer *before* it has `mt` (the early "No tool selected" return). The log branch of `renderHelpContent` was therefore hoisted above that return. + - **[decision]** the release is the helper `dismissSelfLog()`, which drops **only** `selfUpdateLog` and never `updateLogFor`: a tracked keeptui keeps the same per-tool log stickiness as any other tool. In `switchHelpMode` it sits **ahead of** the `selectedMeta` guard (otherwise an empty tracker would have nothing to release with) and repaints `[3]` right there when it actually releases — with an empty tracker nobody else would. +- [x] `model.go` `updateDoneMsg` handler (`:705`): the self branch **ahead of** the `toolByName` early return (`:713`) — otherwise completion for an untracked keeptui would be lost silently and `[U] restart` would be unreachable; success → `selfState = selfUpdated` + statusMsg `updated keeptui` (+ `fetchInstalledCmd` only when `ok`); failure → statusMsg `update failed — see [3]` + `logx.Errorf` **without** writing `selfState`; `updatingFor` cleared unconditionally + - **[deviation, review]** the self branch's discriminator is **`m.isSelfUpdate(msg.tool)`** (`name == keeptui && selfCheckEnabled()`). The plan (and the first implementation) held `selfUpdateLog && msg.tool == selfToolName` so that `[u]` on a tracked `keeptui` row would **stay** an ordinary tool update. Review reversed that: updating keeptui is updating keeptui whichever key starts it, and the "tool path" left a banner announcing an update the card already showed as installed, with `[U] restart` reachable only by running the whole update again (the "release build" row of `TestKeeptuiUpdateSelfHandlingGatedOnBuild`). The version gate is mandatory here: on a dev build the same key must stay a flat tool update, or the whole self feature switches on and the restart re-execs the same working copy (`TestKeeptuiUpdateSelfHandlingGatedOnBuild`). + - **[deviation, review]** the failure branch **does not write `selfState` at all** (the plan required `selfOffered` as the retry). The banner returns on its own as soon as `updatingFor` clears, so any write could only roll the state backwards: an offer collapsed with `[X]` mid-update, a pending `[U] restart` from an earlier successful update, or — from `selfNone` — a `keeptui available` banner with no version that no `selfCheckMsg` can ever fill in (it writes only from `selfNone`). The narrowest correct guard is not to write. Pinned by `TestSelfUpdateDoneFailureKeepsPriorState` (all five prior states). + - **[decision]** the log seed plus `logx.Errorf` were extracted into `recordUpdateFailure(msg)` and reused by both branches — otherwise a third copy of the same code (and a third chance to drift on the log line's format). +- [x] write the tests: the detect handler (self is not dropped with an empty list or no selection; in `modeEditNote` it is dropped and the state stays `selfOffered`; `ErrUnknownManager` → statusMsg); enter in a self confirm **with an empty tracker** sets the guard/log/state; the confirm bar under a self plan shows `keeptui` with a *different* tool selected and with an empty list; done success **with keeptui untracked** → `selfUpdated` + statusMsg; done failure → the banner stays as it was (`U` is the retry wherever the offer was); `[3]` shows the self log with keeptui unselected/untracked and with an empty list; a chunk repaints the log under a foreign selection + - plus: the `showsUpdateLog` table (the tool path untouched), `selfTool()` inheriting `update_cmd`, confirm cancel clearing `updateTarget`, a tool confirm releasing the self override, done success with keeptui **tracked** batching the re-detect, the log released by `j` and by `[h]` (including on an empty tracker) once the update has finished and kept while it is live +- [x] `go test -race ./internal/model/...` — green before Task 5 (plus `go build ./... && go vet ./... && go test -race ./...`, `golangci-lint run` — 0 issues) + +### Task 5: Restart — the model flag, `resolveSelfPath`, exec in `main` + +**Files:** +- Modify: `internal/model/model.go` +- Create: `restart.go` (main package — the shared `restartHint`) +- Create: `restart_unix.go` (`//go:build !windows` — path resolution + exec) +- Create: `restart_windows.go` (`//go:build windows`) +- Create: `restart_unix_test.go` (`//go:build !windows`) +- Modify: `main.go` + +- [x] `model.go`: `case "U"` under `selfUpdated`/`selfUpdatedLater` → `restartRequested = true` + `tea.Quit` + - **[decision]** `selfState` does not move here: after `tea.Quit` nothing renders, and if exec fails the process exits anyway — "the state after a restart" has no reader, so a dedicated enum member would be dead. Pinned by `TestSelfKeys` (one (state, key) → (next state, command, restart flag) table; it absorbed `TestSelfRestartRequest` and `TestSelfRestartNotRequestedElsewhere`). +- [x] `restart.go`/`restart_unix.go`: const `restartHint = "keeptui updated — run keeptui again"` (shared, no build tag); the pure core `resolveSelfPath(executable string, exists func(string) bool, lookPath func(string) (string, error), argv0 string) (string, error)` — **argv0 semantics**: an argv0 with a path separator wins when `exists`; a bare argv0 goes through `lookPath(argv0)` **first** (Linux's `/proc/self/exe` is symlink-resolved and after an upgrade may point at a live old keg path, which exec would loop the restart into); the fallback in both cases is `executable`, **only when `exists(executable)`**, otherwise an error; the wrapper `selfPath()` over `os.Executable`/`os.Stat`/`exec.LookPath`/`os.Args[0]` + - **[decision]** the separator is checked with ``strings.ContainsAny(argv0, `/\`)`` — the same idiom as in `version/brew.go` and `updater/updater.go`; a windows-style path is recognised as a path under any `GOOS`, so the core stays platform-independent and table-testable. + - **[deviation, review]** that rationale died with the untagged file (see the `[deviation, review]` below): the core lives under `//go:build !windows`, so a backslash in argv0 is an ordinary filename character and `exec.LookPath("keeptui")` on unix never answers `keeptui.exe`. Both windows branches were unreachable while `baseProgram`'s comment kept claiming "GOOS-independent". It is now `strings.Contains(argv0, "/")` and `sameProgram` = a `filepath.Base` comparison (the `baseProgram` function is gone); two table rows the build tag already excluded were removed from `restart_unix_test.go` (a `.exe` PATH hit, a windows separator). + - **[deviation, review]** path resolution lives **not** in the untagged `restart.go` but in `restart_unix.go` (`//go:build !windows`) together with `sameProgram`, `selfPath`, `fileExists` and the tests (`restart_unix_test.go`): on Windows `restartSelf` neither resolves nor execs, so in an untagged file these helpers would be dead code under `GOOS=windows` (and `unused` for the linter). The cost is that the core is neither compiled nor tested under `GOOS=windows`; that is an honest trade because nothing calls it there. Only `restartHint` stays untagged — the one thing genuinely shared by both platforms. + - **[decision]** a `lookPath` that returns `("", nil)` counts as a miss (`err == nil && p != ""`) — otherwise an empty string would reach `syscall.Exec`. + - **[deviation, review]** a PATH hit is accepted only when `sameProgram(hit, executable)`: a foreign binary of the same name earlier in PATH would otherwise hijack the restart. +- [x] `restart_unix.go`: `restartSelf()` — `selfPath()` + `syscall.Exec(path, os.Args, os.Environ())`; any error prints `restartHint` and exits normally; `restart_windows.go`: straight to `restartHint` (no exec/spawn) + - **[decision]** the hint prints to **stdout** (`fmt.Println`) and the exit stays zero: the update did succeed, so this is an instruction, not a diagnostic. + - **[decision]** the unix failure path additionally writes `logx.Errorf("restart self: …")` — a resolve miss or a refused exec is a genuine anomaly (keeping the "a log file means something broke" signal). The Windows path logs nothing: there this is expected degradation, not a failure. + - **[deviation, review]** the body was extracted into `restartSelfWith(resolve, exec, out)` — the same kind of seam as the core's `exists`/`lookPath`: otherwise `restartSelf` would have 0% coverage and the hint's wording would be pinned by a tautological test on a constant rather than at the place that prints it (`TestRestartSelfWith`). +- [x] `main.go` `runTUI`: `final, err := p.Run()` → after error handling, type-assert `model.Model` + `RestartRequested()` → `restartSelf()` + - **[deviation, review]** both of the feature's wiring lines in `runTUI` (`.WithAppVersion(ver)` from Task 2 and `restartSelf()` under `RestartRequested()`) were verified by nothing: review showed by mutation that deleting either one leaves the whole test suite green, i.e. exactly two unverified lines stood between the covered model logic and the user. The cut is the same "pure core plus thin shell" idiom as `shellCommand`/`planFor`/`resolveSelfPath`: `newRootModel(meta, ver) model.Model` and `restartIfRequested(final tea.Model, restart func())` (the restart function is injected, like `execve` in `restartSelfWith`), leaving `runTUI` as the shell around `tea.NewProgram`/`p.Run()`. Behavior did not change. The mutation killers are `TestNewRootModelInjectsAppVersion` (the `Init` batch length — a release version queues one command more than a dev one; the commands are never executed) and `TestRestartIfRequested`; deleting the `restartIfRequested` call from `runTUI` is already a compile error (`final` becomes unused). + - **[decision]** `restartIfRequested` asserts on a **local `restarter` interface** rather than on `model.Model`: the flag lives in unexported state behind a key that cannot be pressed from outside the package, so the true branch is only reachable with a stand-in model. The real model is bound to the interface by `var _ restarter = model.Model{}` — a renamed method breaks the build rather than the feature. + - **[note, review]** still uncovered (deliberately, outside the finding's scope): `buildVersion()` — the `debug.ReadBuildInfo` wrapper (its pure core `resolveVersion` is covered), the `logx.SetHeader` header strings, `migrateConfigDir`, and `main()`'s dispatch of `handleCLI` (`handleCLI` itself is table-covered). +- [x] write the tests: a table test for `resolveSelfPath` (bare argv0 → the LookPath result even when `executable` exists; a LookPath miss → `executable` when `exists`; an argv0 with a separator that exists → argv0; one that does not → `executable`; everything missing → an error); `U` at `selfUpdated` sets the flag and returns `tea.Quit`; `RestartRequested()` is false by default + - plus: `lookPath` is not consulted for a path-shaped argv0 (a same-named binary earlier in PATH must not intercept `./keeptui`), an empty argv0 → `executable`, `fileExists` against a real filesystem, `selfPath()` resolving in the test binary's environment, and `RestartRequested()` staying false after `U` on the offer, after `X` and after `q` + - **[deviation, review]** `restartHint`'s wording is pinned in `TestRestartSelfWith` (where it is printed) rather than by a separate test on the constant — that test was the tautology "a const equals itself". The tautological `TestWithAppVersionDefaults` was dropped along with it. +- [x] `go build ./... && go test -race ./...` — green before Task 6 (plus `go vet ./...`, `golangci-lint run` — 0 issues, and cross build/vet under `GOOS=windows`/`GOOS=linux`) + +### Task 6: Verify acceptance criteria + +- [x] check the Overview points: a dev build — no request, no banner; the banner → `[U]`/`[X]`; dismiss → a compact cell with a working `[U]`; the update through the existing pipeline with its log in `[3]` regardless of the selection (every site through `showsUpdateLog`); restart via `[U]` after success **with keeptui untracked**; Windows degradation + - **[note]** every point confirmed against the code; four coverage gaps were closed by new tests in `internal/model/selfupdate_test.go`: `TestSelfCheckCmdServesCache` (`selfCheckCmd` itself is executed — off a warm cache through the new `seedSelfReleaseCache` helper, `version.SaveCache` plus a fresh `ReleaseCheckedAt`, with no network — and its message goes through the handler; previously only `Init`'s batch length was checked), `TestSelfUpdateLogSuppressesHelpNav` (the `setHelpContent` site: with a live self-log `helpEntries`/`helpBase` stay empty and `j` does not start the spotlight) and `TestSelfUpdateLogSkipsHelpFetch` (the `autoFetchCmdsForSelected` site: `helpLoadingFor` is not set and the `--help` probe does not run). Both site tests were mutation-checked: with the previous guards (`updateLogFor != mt.Name` / `updateLogFor == mt.Name`) they fail. + - **[deviation, review]** of those four, `TestDevBuildShowsNoBanner` was deleted: with respect to the build it names the test was vacuous — nothing between `New` and the assertions writes `selfState`, so it passed verbatim with `WithAppVersion("v0.4.2")` too, and its "no banner" half duplicated a row of `TestSelfBannerInStatusBar`. Both halves of the requirement are pinned more precisely: the request by `TestInitSelfCheckGatedOnVersion`, the UI by `TestKeeptuiUpdateSelfHandlingGatedOnBuild` (plus the `selfNone` row of `TestSelfStateSitesAreExhaustive` for the compact cell). The `CLAUDE.md` citation was corrected. + - **[note]** Windows degradation was verified by cross build and `vet` under `GOOS=windows` (and `GOOS=linux`); the hint's wording is pinned where it is printed (`TestRestartSelfWith`, `restart_unix_test.go`), and `restartSelf` on windows is three lines with no exec/spawn and no path resolution. +- [x] check the edge cases: rate limit/network → no banner plus logging of unexpected failures only; a repo with no releases → conclusive, no repeat requests; latest ≤ current → `selfNone`; an update failure → **the banner stays as it was** (`U` is the retry wherever the offer was); a late detect under an input → dropped; the `updatingFor` guard in both directions; a tracked keeptui — a shared cache with an unpoisoned card (`CheckedAt` untouched, the release tuple not wiped) and `↑` going out after the update + - **[note]** covered by existing tests: `TestSelfCheckMsgStates` (rate limit / network / non-semver / no release → `selfNone`), `TestSelfLatestNoReleases` (the conclusive stamp, the repeat call making no request), `TestSelfUpdateDoneFailureKeepsPriorState` (a table over all five prior states: `selfState` is not written at all and an offer stays an offer — the neighbouring `TestSelfUpdateDoneFailure` only checks the log seed and the `[3]` repaint on one fixture, where its state assertion on the offer is tautological), `TestSelfDetectedAcceptance` (dropped under `modeEditNote`/`modeHotkeys`), `TestSelfLatestColdFetch` (`CheckedAt` zero) + `TestSelfLatestServedFromFullPass` (the shared cache) + `TestSelfLatestDroppedReleaseKeepsSharedTuple` (a 404 does not strip the card), `TestSelfUpdateDoneSuccessTracked` (the re-detect puts `↑` out). + - **[deviation-free note]** the `updatingFor` guard was covered in one direction only (`[U]` under a tool update). `TestToolUpdateKeyBlockedBySelfUpdate` was added: `[u]` on a tool with an available release while a self-update runs. Review later made the refusal **speak** in both directions (the shared `updateBusyStatus`) and added `TestSelfUpdateKeyInertWithoutBanner` — at `selfNone` (including on a dev build and on a pseudo-version) `U` answers nothing. + - **[note]** "logging of unexpected failures only" in `selfCheckCmd` was verified by reading the code plus the nil-err path in `TestSelfCheckCmdServesCache`: the network-error branch cannot be executed from the `model` package — there is no `testAPIBase` seam here, and a live request in a test is not acceptable. **Review** then reduced the requirement to "log nothing at all" (`version` already records both transport and status), so the command has no logging branch left. +- [x] run the full matrix: `go build ./... && go vet ./... && go test -race ./...` (plus cross build/vet under `GOOS=windows` and `GOOS=linux`) — green +- [x] run the linter: `golangci-lint run` — 0 issues + +### Task 7: Update documentation + +- [x] update `CLAUDE.md` (skill `docs-sync`): the self-check (`SelfLatest`, `ReleaseCheckedAt` and why not `CheckedAt`), the `selfState` machine + the derived `selfUpdating()` + the `U`/`X` keys, the self branch of the update pipeline, the `showsUpdateLog` predicate and the full list of its sites, the restart flow (`RestartRequested` → `restartSelf`, the argv0 resolution semantics), `WithAppVersion` + - edits: **Entry point** (`WithAppVersion`, `restart.go`/`restart_unix.go`/`restart_windows.go`, `RestartRequested` after `p.Run()`), the package table (`version` → `selfcheck.go`), the `model` file table (`selfCheckCmd`/`detectUpdateCmd`), the `Init()` batch (the self-check next to `fetchRateCmd`, not last), the `[?]` overlay (the `Self` group in column 2, the 20×75 budget, column 1 as the tallest at 16 rows), **Input modes** (`selfState` is not a mode), the `[u]` bullet (`acceptsUpdateDetect`, `updateTarget`), the **Live log** (`showsUpdateLog`) and **Spinner + completion** (`recordUpdateFailure`) sub-bullets, **Panel `[3]` modes** (`dismissSelfLog`, the `showsUpdateLog` gate), **Help bar** (a two-element right group), **Status-message lifecycle** (the statusMsg > banner > hints order), **Session error log** (the new `Errorf` sites), **GitHub API** (+1 request/24h, the `ReleaseCheckedAt`/`selfCachedTag` paragraph plus the "a 404 is stored in the `ReleaseMissing` flag" paragraph, "`SelfLatest` has no force variant"), the storage table, test isolation (`testAPIBase` is private → `seedSelfReleaseCache`) + - a new large **Self-update (`U`/`X`)** bullet with sub-bullets: the version gate, the check, the `selfState` machine, banner rendering, the right group, the keys, detect → confirm, `showsUpdateLog()` plus the full list of sites, completion, restart, path resolution +- [x] update `README.md`: the user-visible feature — the banner, the `U`/`X` keys, the restart after an update + - a new **Updating keeptui itself** section (plus a Contents link), a bullet under **Features**, a `U`/`X` row in the `[1] Tools` table, a note about the self-log in the panel `[3]` section, +1 request in **GitHub API and token**, a `cache.json` mark in the storage table + - **[decision]** per the `docs-sync` skill a third document was synced too — **`ARCHITECTURE.md`** (not named in the plan, but the drift applies): the intro (`WithAppVersion`, the three `restart*.go`), the `internal/version` row, the two non-per-tool batch elements in **Data flow**, the "one predicate owns `[3]`" invariant, a new **Self-update and restart (`U` / `X`)** section, `ReleaseCheckedAt`/`ReleaseMissing` in the cache bullet and the storage table, and a note about `testAPIBase`/`seedSelfReleaseCache` in **Testing** + - **[note, review]** the documents were edited again in every review iteration (the state machine, the version gate, `updateTarget`, "a failure writes no state", the order of the checks in `U`, the 404 cache contract) — all three plus this plan describe the same behavior. + - **[note]** the skill's drift-prone spots were checked: the mermaid graph ↔ the real imports (no new edges — `main → logx` already existed and `restart_unix.go` reuses it), the 13 `inputMode` values (no new modes), the key tables ↔ the `modeNormal` case switch, the test seams, `Stack` ↔ `go.mod`'s direct dependencies, the timeout/limit constants (10s/10min/24h/20/12 cells), the path table — no discrepancies beyond the edits listed above +- [x] move this plan into `docs/plans/completed/` — **not done here**: the harness moves it after all phases (shifting the file mid-run breaks every later review/finalize/stats step that reads this path) + +## Post-Completion + +*Manual/external actions only — no checkboxes.* + +**Manual smoke in a live TUI** (needs an older version installed, e.g. brew or `go install` of the previous tag): +- start → the banner with the current version from releases; `[X]` → the compact cell by the gauge; `[U]` → the confirm with the detected command → a live log in `[3]` → `keeptui updated — [U] restart`. +- `[U]` restart: in tmux/iTerm the tab survives and the new keeptui comes up with the same PID (unix), and `--version` shows the new tag. Check separately on Linux with `go install`: the restart must launch the new binary (the argv0/LookPath semantics). +- dev build (`go run .`): no banner, no `stanlyzoolo/keeptui` entry in `cache.json` (when keeptui is untracked), `logs/` empty. +- network down at startup: no banner, the app lives as before. + +**Caveats:** +- The brew formula can lag behind the GitHub release: after an update and restart the installed version is still older than latest, so the banner comes back. An honest reflection of reality; not fought. +- Quitting keeptui mid-self-update behaves like it does for tools: the detached updater process lives on by itself. +- `demo-gifs` are not regenerated: the banner appears only when an update is genuinely available, and the demo environment has none. diff --git a/internal/model/commands.go b/internal/model/commands.go index d43229f..9c7ba00 100644 --- a/internal/model/commands.go +++ b/internal/model/commands.go @@ -223,6 +223,24 @@ func fetchRateCmd() tea.Cmd { }) } +// selfCheckCmd fetches keeptui's own latest release tag — one release-only +// request per cache TTL window (see version.SelfLatest) — and emits a +// selfCheckMsg. It takes no parameters: the repo is a constant and the +// is-it-newer comparison belongs to the handler, which knows m.appVersion. +func selfCheckCmd() tea.Cmd { + return safeCmd("selfCheckCmd", func() tea.Msg { + // No logging here, the same as remoteCmd: version owns it end to end + // (doGH logs a transport failure, classifyStatus a bad status, both with + // more detail than this layer has), and a second line per failure would + // mean every offline launch writes two — inflating the "a log file means + // something went wrong" signal instead of sharpening it. "No release + // published" is not a failure at all: it arrives as an empty tag with a + // nil error. + latest, err := version.SelfLatest() + return selfCheckMsg{latest: latest, err: err} + }) +} + func fetchChangelogCmd(githubField, toolName string) tea.Cmd { return changelogCmd(githubField, toolName, false) } @@ -396,11 +414,12 @@ func (m *Model) autoFetchCmdsForSelected() tea.Cmd { } if mt, ok := m.selectedMeta(); ok { switch { - case m.updateLogFor == mt.Name: - // The tool's live update log owns [3]: don't fetch help (and don't - // set helpLoadingFor) — a late helpOutputMsg or the "Loading..." - // state would clobber the log. Just render the log branch, scrolled - // to the tail so the newest output is visible on re-selection. + case m.showsUpdateLog(): + // A live update log owns [3] — this tool's, or keeptui's own, which is + // selection-independent: don't fetch help (and don't set + // helpLoadingFor) — a late helpOutputMsg or the "Loading..." state + // would clobber the log. Just render the log branch, scrolled to the + // tail so the newest output is visible on re-selection. m.setHelpContent() m.helpViewport.GotoBottom() case m.helpMode == helpModeReadme: @@ -453,10 +472,15 @@ const updateTimeout = 10 * time.Minute // updater.Detect spawns subprocesses (go version -m, cargo install --list) and // must never run inside Update(), like every other probe. Emits an // updateDetectedMsg; the handler enters the confirm mode or shows a hint. -func detectUpdateCmd(t loader.Tool) tea.Cmd { +// +// self marks a [U] press on the self-update banner rather than a [u] on a tool +// row: the same detection, but the result belongs to a banner with no row behind +// it, which is what the handler's relevance gate needs to know (see +// acceptsUpdateDetect). +func detectUpdateCmd(t loader.Tool, self bool) tea.Cmd { return safeCmd("detectUpdateCmd", func() tea.Msg { plan, err := updater.Detect(t) - return updateDetectedMsg{tool: t.Name, plan: plan, err: err} + return updateDetectedMsg{tool: t.Name, plan: plan, err: err, self: self} }) } diff --git a/internal/model/mode.go b/internal/model/mode.go index fd64569..df86d98 100644 --- a/internal/model/mode.go +++ b/internal/model/mode.go @@ -9,6 +9,7 @@ import ( "github.com/stanlyzoolo/keeptui/internal/launcher" "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keeptui/internal/updater" "github.com/stanlyzoolo/keeptui/internal/version" ) @@ -404,31 +405,49 @@ func flushPendingLaunch(mdl tea.Model, cmd tea.Cmd) (tea.Model, tea.Cmd) { // updateConfirmUpdate handles the modeConfirmUpdate dialog (modeled on // modeConfirmUntrack): enter launches the update — set updatingFor, reset the -// live log to the target tool, and fire the streaming command plus the spinner -// tick; esc (or any other key) cancels back to modeNormal. The plan awaiting -// confirmation lives in m.updatePlan. +// live log to the target, and fire the streaming command plus the spinner tick; +// esc (or any other key) cancels back to modeNormal. The plan awaiting +// confirmation lives in m.updatePlan and the name it belongs to in +// m.updateTarget, so there is one path here for a tool and for keeptui itself: +// reading the name off the selection instead would silently cancel a +// self-update whenever the tracker is empty, and would retarget a tool's plan +// onto whatever row the user moved to while detection was running. func (m Model) updateConfirmUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": m.mode = modeNormal - mt, ok := m.selectedMeta() - if !ok { + target := m.updateTarget + m.updateTarget = "" + if target == "" { return m, nil } - m.updatingFor = mt.Name + m.updatingFor = target m.updateLog = nil - m.updateLogFor = mt.Name + m.updateLogFor = target + // keeptui's own log owns [3] under every selection — an untracked keeptui + // has no row of its own (see showsUpdateLog) — while a tool's log is + // per-tool sticky, so taking the single buffer over also releases a + // finished self-update's claim on the panel. Read off selfUpdating(), which + // is updatingFor (just set to target) through the feature's version gate: + // with the self-update off, keeptui's row keeps the same per-tool stickiness + // as any other row. + m.selfUpdateLog = m.selfUpdating() m.briefViewport.SetContent(m.renderCard()) // Text-change transition: [3] switches from help to the live log, so // the entry index empties and any spotlight cursor resets. m.setHelpContent() return m, tea.Batch( m.spinner.Tick, - startUpdateCmd(m.updatePlan, mt.Name), + startUpdateCmd(m.updatePlan, target), ) default: - // esc or any other key cancels. + // esc or any other key cancels. The plan goes with the target: the two + // are written together at the single detect site and read together by the + // confirm bar, so "no pending plan" has to mean the same thing here as it + // does on the path where the result was dropped before it ever landed. m.mode = modeNormal + m.updateTarget = "" + m.updatePlan = updater.Plan{} return m, nil } } diff --git a/internal/model/mode_test.go b/internal/model/mode_test.go index 0daaa99..366ad3e 100644 --- a/internal/model/mode_test.go +++ b/internal/model/mode_test.go @@ -1093,9 +1093,12 @@ func TestUpdateKeyWithoutUpdate(t *testing.T) { } } -// TestUpdateKeyWhileUpdatingNoop: [u] while an update is already running is a -// no-op — one update at a time, no queue. +// TestUpdateKeyWhileUpdatingNoop: [u] while an update is already running starts +// nothing — one update at a time, no queue — and says so, like every other +// blocked action here (the running update's own indicator is a card spinner, +// invisible unless that tool is selected). func TestUpdateKeyWhileUpdatingNoop(t *testing.T) { + shrinkStatusTTL(t) m := newUpdateTestModel() m.updatingFor = "rg" @@ -1104,12 +1107,11 @@ func TestUpdateKeyWhileUpdatingNoop(t *testing.T) { if nm.mode != modeNormal { t.Errorf("mode = %d, want modeNormal (no confirm)", nm.mode) } - if cmd != nil { - t.Errorf("cmd = %v, want nil (no detection while updating)", cmd) - } - if nm.statusMsg != "" { - t.Errorf("statusMsg = %q, want empty", nm.statusMsg) + if nm.statusMsg != updateBusyStatus { + t.Errorf("statusMsg = %q, want %q", nm.statusMsg, updateBusyStatus) } + // Only the status expiry — no detection was fired. + assertOnlyExpiryTick(t, cmd) } // TestUpdateDetectedEntersConfirm: a successful detection for the selected tool @@ -1126,6 +1128,10 @@ func TestUpdateDetectedEntersConfirm(t *testing.T) { if nm.updatePlan.Display != "brew upgrade ripgrep" { t.Errorf("updatePlan.Display = %q, want the detected command", nm.updatePlan.Display) } + // The plan's target is fixed here, not re-read from the selection on enter. + if nm.updateTarget != "rg" { + t.Errorf("updateTarget = %q, want rg", nm.updateTarget) + } if bar := nm.renderStatusBar(); !strings.Contains(bar, "brew upgrade ripgrep") { t.Errorf("status bar = %q, want it to show the plan command", bar) } @@ -1205,6 +1211,7 @@ func TestUpdateConfirmEnterStarts(t *testing.T) { m := newUpdateTestModel() m.mode = modeConfirmUpdate m.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "true"} + m.updateTarget = "rg" m.updateLog = []string{"stale"} updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -1232,6 +1239,7 @@ func TestUpdateConfirmEscCancels(t *testing.T) { m := newUpdateTestModel() m.mode = modeConfirmUpdate m.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "true"} + m.updateTarget = "rg" updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) nm := updated.(Model) @@ -1241,6 +1249,35 @@ func TestUpdateConfirmEscCancels(t *testing.T) { if nm.updatingFor != "" { t.Errorf("updatingFor = %q, want empty (nothing started)", nm.updatingFor) } + if nm.updateTarget != "" { + t.Errorf("updateTarget = %q, want it cleared with the cancelled dialog", nm.updateTarget) + } + // The plan is written with the target at the single detect site and read with + // it by the confirm bar, so cancelling has to drop both — otherwise "no + // pending plan" means something different here than on the dropped-result + // path (TestSelfDetectedAcceptance asserts both fields there). + if nm.updatePlan.Display != "" || nm.updatePlan.Manager != "" || nm.updatePlan.Argv != nil { + t.Errorf("updatePlan = %+v, want it cleared with the cancelled dialog", nm.updatePlan) + } +} + +// TestUpdateConfirmKeepsDetectedTarget: the target is the one detection resolved, +// so a selection that moved while detection ran cannot retarget the dialog onto +// another tool's row. +func TestUpdateConfirmKeepsDetectedTarget(t *testing.T) { + m := newUpdateTestModel() + m.meta = append(m.meta, loader.ToolMeta{Name: "fd"}) + m.tools = loader.ToolsFromMeta(m.meta) + m.mode = modeConfirmUpdate + m.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "brew upgrade ripgrep"} + m.updateTarget = "rg" + m.metaSelected = len(m.meta) - 1 // the user moved onto fd meanwhile + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + nm := updated.(Model) + if nm.updatingFor != "rg" || nm.updateLogFor != "rg" { + t.Errorf("updatingFor = %q, updateLogFor = %q, want both rg", nm.updatingFor, nm.updateLogFor) + } } // TestSpinnerTicksWhileUpdating: the spinner tick loop keeps rescheduling while diff --git a/internal/model/model.go b/internal/model/model.go index dfd286b..91e6d82 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,6 +2,7 @@ package model import ( "errors" + "regexp" "strings" "time" @@ -123,14 +124,69 @@ const ( // final "installed"/error lines); older output can be dropped without loss. const updateLogMaxLines = 500 +// updateBusyStatus is what [u] and [U] report while another update runs (one at +// a time, no queue). Shared so the two refusals cannot word it differently. +const updateBusyStatus = "another update is running" + +// selfToolName is the name keeptui uses for itself inside the update pipeline: +// the updater's detection target, the updatingFor/updateLogFor guard value and +// the label the confirm bar and status messages show. A constant rather than a +// meta.yaml lookup — the feature's main case is a keeptui that is not tracked. +const selfToolName = "keeptui" + +// selfState is the self-update banner's state machine. There is deliberately no +// "updating" member: whether a self-update is in flight is derived from the +// update pipeline itself (selfUpdating), so the two can never disagree. +// +// selfNone no newer release known — no banner at all +// selfOffered full banner: "keeptui available — [U] update [X] dismiss" +// selfDismissed collapsed to a compact "keeptui ↑ [U]" cell by the gauge +// selfUpdated full banner: "keeptui updated — [U] restart [X] later" +// selfUpdatedLater collapsed to a compact "keeptui [U] restart" cell +// +// Five sites switch on it — [U] (selfUpdateKey), [X], selfBannerCells, +// selfCompactCell and the [?] Self group — and .golangci.yml carries no +// exhaustiveness linter, so a sixth member would compile while silently missing +// some of them. Every one of those switches therefore enumerates the whole enum +// and ends in a default (the two key handlers log there), and +// TestSelfStateSitesAreExhaustive walks selfNone..selfStateCount through all +// five. selfStateCount must stay last. +type selfState int + +const ( + selfNone selfState = iota + selfOffered + selfDismissed + selfUpdated + selfUpdatedLater + selfStateCount +) + +// selfCheckMsg carries the result of the startup self-check — keeptui's own +// latest release tag. An empty latest with a nil error is the conclusive answer +// "no release published", not a failure. The comparison deliberately lives in +// the handler rather than the command, so it runs against the model's +// appVersion (the version of the running binary). +type selfCheckMsg struct { + latest string + err error +} + // updateDetectedMsg carries the result of updater.Detect for a tool, run in a // tea.Cmd because detection spawns subprocesses (go version -m, cargo install // --list) and must never run on the Update thread. The handler enters the // confirm mode on success and shows a hint on ErrUnknownManager. +// +// self marks a detection fired by the self-update banner ([U]) rather than by +// [u] on a tracked tool row. The two differ in what makes a landed result still +// relevant: a tool result must match the selection, while keeptui's own has no +// selection to match (the main case is a keeptui that is not tracked) — see +// acceptsUpdateDetect. type updateDetectedMsg struct { tool string plan updater.Plan err error + self bool } // updateChunkMsg carries one segment of the running update's merged @@ -237,15 +293,38 @@ type Model struct { // updatingFor twins refreshingFor for the in-TUI update flow: it holds the // name of the tool currently being updated (empty = idle), drives the card // spinner and doubles as the single-update-at-a-time guard. updatePlan is - // the plan awaiting confirmation in modeConfirmUpdate. updateLog is the live - // merged stdout+stderr buffer for panel [3]; updateLogFor is the tool it - // belongs to, so navigating away shows normal help and navigating back shows - // the log again (the buffer survives until the next update starts). + // the plan awaiting confirmation in modeConfirmUpdate and updateTarget the + // name it belongs to — resolved when the plan was detected, not when enter + // is pressed, so the dialog cannot be retargeted by a selection move and + // keeptui's own update (which has no row, and no selection when the tracker + // is empty) needs no separate identity. updateLog is the live merged + // stdout+stderr buffer for panel [3]; updateLogFor is the tool it belongs to, + // so navigating away shows normal help and navigating back shows the log + // again (the buffer survives until the next update starts). updatingFor string updatePlan updater.Plan + updateTarget string updateLog []string updateLogFor string + // appVersion is the version of the running binary (ldflag or buildinfo, + // injected by WithAppVersion) and the gate for the whole self-update + // feature: empty or "dev" means no self-check request and no banner. It is + // also what the latest release is compared against — deliberately not + // m.versions[selfToolName].Installed, whose three detection fallbacks + // (--version, brew dir, cargo install --list) can report a version that is + // not the one this process is running. + appVersion string + // selfLatest is keeptui's own latest release tag and selfState the banner + // state machine (see selfState). selfUpdateLog marks the live [3] log as + // keeptui's own, so it stays visible regardless of the selection — an + // untracked keeptui has no row to select. restartRequested is the exit flag + // main reads off the model p.Run() returns. + selfLatest string + selfState selfState + selfUpdateLog bool + restartRequested bool + meta []loader.ToolMeta // metaSelected is an index into filteredMeta() — a tool index, never a // screen row. Grouping only reorders that projection and inserts @@ -387,11 +466,222 @@ func New(meta []loader.ToolMeta) Model { return m } +// WithAppVersion injects the running binary's version (main.buildVersion) and is +// what switches the self-update feature on. A builder rather than a New +// parameter because New( is called from a hundred-odd tests: the zero value +// leaves the feature off, so none of them had to change. +func (m Model) WithAppVersion(v string) Model { + m.appVersion = v + return m +} + +// RestartRequested reports whether the user accepted [U] restart after a +// successful self-update. main reads it off the model p.Run() returns — after +// Bubble Tea has restored the terminal — and re-execs the new binary. +func (m Model) RestartRequested() bool { return m.restartRequested } + +// selfCheckEnabled gates the self-check: a build with no release behind it has +// no version worth comparing against a release tag, so it makes no request and +// shows no banner. +// +// "dev" is the ldflag default, but it is not the only shape a working copy +// reports. Since Go 1.24 a plain `go build .` / `go install .` stamps the module +// version from VCS, so this project's own documented dev commands yield a +// pseudo-version (v0.0.0-20260725115912-1be4bafa79c8) or, with uncommitted +// changes, a +dirty suffix. Those canonicalize as valid semver pre-releases that +// sort below every real tag, so without this check a developer's own build would +// announce an update and offer to overwrite itself with the latest release. +func (m Model) selfCheckEnabled() bool { + return m.appVersion != "" && m.appVersion != "dev" && !isDevVersion(m.appVersion) +} + +// pseudoVersionRe matches the Go pseudo-version suffix — a 14-digit UTC +// timestamp plus a 12-char commit prefix — which is common to all three of its +// forms (vX.0.0--, vX.Y.Z-0.-, vX.Y.Z-pre.0.-). +// The separator before the timestamp is a dash in the first form and a dot in +// the other two, where the timestamp extends an existing pre-release. +var pseudoVersionRe = regexp.MustCompile(`[-.][0-9]{14}-[0-9a-f]{12}$`) + +// isDevVersion reports whether v describes a working copy rather than a release. +// Build metadata is enough on its own: buildinfo appends +dirty for uncommitted +// changes and no release tag of this project carries a + at all. +func isDevVersion(v string) bool { + if strings.Contains(v, "+") { + return true + } + return pseudoVersionRe.MatchString(v) +} + +// isSelfUpdate reports whether an update of the named tool is keeptui's own +// self-update — the one that owns panel [3] under every selection and ends in the +// [U] restart offer — rather than a plain tool update. Every update of keeptui is +// a self-update regardless of which key started it, so the target name decides +// which *kind* of update it is; but the feature as a whole is gated on the running +// build having a release behind it, exactly like the startup check +// (selfCheckEnabled), and the name alone would smuggle it past that gate. +// +// The gate is not cosmetic. On a dev build there is no check, no banner and +// nothing to compare, so [u] on a tracked keeptui row must stay what it is for +// every other row: a plain tool update. Announcing a restart there would also be +// a lie — a working copy's argv0 carries a path separator, so resolveSelfPath +// re-execs that very binary and would silently return the user to the pre-update +// version. +func (m Model) isSelfUpdate(name string) bool { + return name == selfToolName && m.selfCheckEnabled() +} + +// selfUpdating reports whether the update currently in flight is keeptui's own. +// Derived from the update pipeline's own state instead of a selfState member, so +// "a self-update is running" can never drift out of sync with updatingFor. +func (m Model) selfUpdating() bool { + return m.isSelfUpdate(m.updatingFor) +} + +// selfTool is the updater's detection target for keeptui itself. A tracked +// keeptui is used as tracked, so a update_cmd override in meta.yaml governs the +// self-update exactly as it governs a [u] on that row; otherwise the entry is +// synthesized — the feature's main case is a keeptui that is not in the tracker +// at all, and updater.Detect only needs the name (plus the override). +func (m Model) selfTool() loader.Tool { + if t, ok := m.toolByName(selfToolName); ok { + return t + } + // The GitHub field carries the same host-prefixed form every tracked tool + // has ("github.com/owner/repo"), the shape openURLCmd and NormalizeRepo + // expect — updater.Detect ignores it, but a wrong-shaped value here would be + // a trap for the next reader of the synthesized entry. + return loader.Tool{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo} +} + +// showsUpdateLog reports whether panel [3] currently belongs to an update log +// instead of help. Two ways to own the panel: the selected tool is the one whose +// log is buffered (the tool path — the buffer is sticky per tool, so navigating +// away shows normal help and navigating back shows the log again), or the log is +// keeptui's own, which stays visible regardless of the selection because an +// untracked keeptui has no row to select and the tracker can even be empty. +// +// The single predicate every updateLogFor-bound site shares — the inset title, +// the renderHelpContent branch, the per-chunk repaint, the setHelpContent entry +// gate and the help fetch — so they can never disagree about who owns [3]. +func (m Model) showsUpdateLog() bool { + if m.selfUpdateLog { + return true + } + if m.updateLogFor == "" { + return false + } + mt, ok := m.selectedMeta() + return ok && mt.Name == m.updateLogFor +} + +// dismissSelfLog releases a *completed* self-update log's claim on panel [3], +// reporting whether it did. A live self-update keeps the panel — the log is the +// only place its output is visible. Called from the paths that mean "show me +// something else": an explicit [h]/[m]/[r] and a selection move. +// +// Only the override is dropped, not updateLogFor: a tracked keeptui keeps the +// same per-tool stickiness every other tool has. +func (m *Model) dismissSelfLog() bool { + if !m.selfUpdateLog || m.selfUpdating() { + return false + } + m.selfUpdateLog = false + return true +} + +// selfUpdateKey is what [U] does: start the offered self-update, or quit into the +// restart once one has finished. A named helper like every other multi-step key +// (toggleGroupByTag, switchHelpMode), so the normal-mode switch keeps one line +// per key instead of the two guards plus a nested switch this needs. +// +// With no banner on screen (selfNone) the key is unbound — and on a dev build +// that is the only reachable state, where the feature is documented as absent end +// to end. So the "one update at a time" refusal is checked *after* the state, +// never before it: answering `another update is running` there would make U the +// one audible piece of a self-update surface that is supposed to be off. +func (m *Model) selfUpdateKey() tea.Cmd { + if m.selfState == selfNone { + return nil + } + // One update at a time, self or tool: while updatingFor is set the key + // reports itself instead of doing nothing — the banner on screen still + // advertises [U], and during a *tool* update the only other indicator is a + // card spinner that is invisible unless that tool is selected. + if m.updatingFor != "" { + return m.setStatus(updateBusyStatus) + } + switch m.selfState { + case selfOffered, selfDismissed: + // Detection spawns subprocesses (go version -m, cargo install --list), + // so it can only run as a command. The banner state is deliberately left + // alone — until the confirm dialog takes over, U stays a retry. + return detectUpdateCmd(m.selfTool(), true) + case selfUpdated, selfUpdatedLater: + // The new binary is on disk; only this process is still the old one. + // Quitting is the whole restart from the model's side: the flag rides the + // final model out of p.Run(), and main re-execs after Bubble Tea has + // restored the terminal — doing it from inside Update would exec on top + // of the alt screen. + m.restartRequested = true + return tea.Quit + case selfNone: + // Unreachable — the early return above owns it — but named so that the + // switch enumerates the whole enum like every other selfState site. + default: + logx.Errorf("self-update: [U] on unhandled selfState %d", m.selfState) + } + return nil +} + +// acceptsUpdateDetect reports whether a landed detection result may still open +// the confirm dialog. Both paths refuse while an update runs (one at a time, no +// queue) and while any input mode owns the keyboard: detection spawns +// subprocesses and the answer can arrive seconds later, so a confirm dialog +// opening under an editor, a search or an overlay would steal the keystroke +// aimed at it (the same reasoning as launchDoneMsg's mode gate). Beyond that a +// tool result must still match the selection — the user may have moved on — +// while keeptui's own has no selection to match: it is typically untracked and +// the tracker may be empty. +func (m Model) acceptsUpdateDetect(msg updateDetectedMsg) bool { + if m.updatingFor != "" || m.mode != modeNormal { + return false + } + if msg.self { + return true + } + mt, ok := m.selectedMeta() + return ok && mt.Name == msg.tool +} + +// recordUpdateFailure seeds the live log with the exit error when the command +// produced no output at all (empty argv, missing manager binary, +// StdoutPipe/Start error, immediate non-zero exit) — otherwise [3] would still +// read "starting update…" while the status bar points there for the reason — and +// records the failure for post-hoc research. The update argv never carries the +// token and msg.err is an exec/exit error, so both stay token-free. +func (m *Model) recordUpdateFailure(msg updateDoneMsg) { + if m.updateLogFor == msg.tool && len(m.updateLog) == 0 { + m.updateLog = append(m.updateLog, "update failed: "+msg.err.Error()) + if m.showsUpdateLog() { + m.helpViewport.SetContent(m.renderHelpContent()) + m.helpViewport.GotoBottom() + } + } + logx.Errorf("update failed: tool=%s manager=%s err=%v tail=%q", + msg.tool, m.updatePlan.Manager, msg.err, tailLines(m.updateLog, 5)) +} + func (m Model) Init() tea.Cmd { cmds := make([]tea.Cmd, 0, len(m.tools)*2+1) // Seed the rate-limit signal up front; on warm-cache starts remote fetches // make no request, so this is the only observation that populates m.rate. cmds = append(cmds, fetchRateCmd()) + // Right after the rate seed and deliberately not last: the README seed at the + // end of this function has to stay the final element of the batch + // (TestInitFetchesReadmeForSelected executes it). + if m.selfCheckEnabled() { + cmds = append(cmds, selfCheckCmd()) + } for _, t := range m.tools { cmds = append(cmds, fetchInstalledCmd(t)) if t.GitHub != "" { @@ -532,6 +822,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case selfCheckMsg: + // The startup self-check answered. A failure (rate limit, network, 5xx) + // leaves the state at selfNone: no banner, the rest of the app is + // unaffected, and the record was already written inside version (doGH / + // classifyStatus) — selfCheckCmd itself logs nothing, see its comment. + // An empty tag is the conclusive "no release published" answer and means + // no banner either. Only a state that has not been acted on is written — + // in either direction: neither the "not newer" path nor a newer tag may + // walk back a dismissal, a finished update or a pending restart, and the + // check answers once per session anyway. + if msg.err != nil || msg.latest == "" || m.selfState != selfNone { + return m, nil + } + if version.IsNewer(m.appVersion, msg.latest) { + m.selfLatest = msg.latest + m.selfState = selfOffered + } + return m, nil + case spinner.TickMsg: // Animate while a refresh ([r]) or an update ([u]) is in flight; once // both refreshingFor and updatingFor are cleared (by the remoteMsg / @@ -656,22 +965,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case updateDetectedMsg: - // Detection result for a [u] press. Drop it if the target is no longer - // the selected tool (the user moved on) or an update is already running. - // ErrUnknownManager is not a dead-end dialog — just a hint pointing at - // update_cmd / manual install. On success, stash the plan and open the - // confirm dialog. - mt, ok := m.selectedMeta() - if !ok || mt.Name != msg.tool || m.updatingFor != "" { + // Detection result for a [u] (tool) or [U] (keeptui itself) press; the + // relevance gate differs per path, see acceptsUpdateDetect. A dropped + // result changes nothing — for the self path the banner stays at + // selfOffered, where [U] is a retry. ErrUnknownManager is not a dead-end + // dialog either, just a hint: the tool path can point at update_cmd, while + // keeptui's own manual route is whatever installed it. On success, stash + // the plan together with the name it was detected for (which the confirm + // dialog and its status bar read) and open the confirm dialog. + if !m.acceptsUpdateDetect(msg) { return m, nil } if msg.err != nil { if errors.Is(msg.err, updater.ErrUnknownManager) { + // The wording branches on what the target *is*, not on which key + // asked: the same failure of the same binary must read the same + // way from [U] and from [u] on a tracked keeptui row (see + // isSelfUpdate). msg.self keeps the one meaning only it has — "no + // selection to match" — in acceptsUpdateDetect. + if m.isSelfUpdate(msg.tool) { + return m, m.setStatus("no known updater for " + selfToolName + " — update manually") + } return m, m.setStatus("no known updater for " + msg.tool + " — set update_cmd or [o] releases") } return m, m.setStatus("update detect failed: " + msg.err.Error()) } m.updatePlan = msg.plan + m.updateTarget = msg.tool m.mode = modeConfirmUpdate return m, nil @@ -692,10 +1012,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.updateLog) > updateLogMaxLines { m.updateLog = m.updateLog[len(m.updateLog)-updateLogMaxLines:] } - // Repaint [3] and autoscroll only when the updating tool is the one - // selected; a chunk for a backgrounded update leaves the visible - // panel (another tool's help) untouched. - if mt, ok := m.selectedMeta(); ok && mt.Name == m.updateLogFor { + // Repaint [3] and autoscroll only while the log is the panel's + // content; a chunk for a backgrounded tool update leaves the visible + // panel (another tool's help) untouched, while a self-update's log + // owns the panel regardless of the selection. + if m.showsUpdateLog() { m.helpViewport.SetContent(m.renderHelpContent()) m.helpViewport.GotoBottom() } @@ -708,6 +1029,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // rescheduling itself; the live log in [3] survives until the next // update begins. Re-render the card so its title drops the spinner. m.updatingFor = "" + // keeptui's own update is settled first, ahead of the toolByName lookup: + // that early return drops the message whenever the tool is not tracked, + // which for keeptui is the normal case — the update would finish silently + // and the [U] restart offer would never appear. The discriminator is + // isSelfUpdate, not the bare name: an update of keeptui is a self-update + // whichever key started it (so [u] on a tracked keeptui row ends here too + // and offers the same restart instead of leaving a banner that contradicts + // the card), but only on a build where the feature is live at all — on a dev + // build the same keypress falls through to the plain tool path below. + if m.isSelfUpdate(msg.tool) { + m.briefViewport.SetContent(m.renderCard()) + if msg.err != nil { + // selfState is deliberately left exactly as it was found. Whatever + // the banner showed before the update it shows again the moment + // updatingFor clears: the offer, where [U] is the retry, or the + // compact cell when [X] folded it mid-update, or nothing at all when + // the check never offered anything. Forcing selfOffered here instead + // announced "keeptui available" with no version behind it whenever + // the update started from selfNone (a rate-limited or offline startup + // check, a check that said "not newer", or [u] on a tracked row), and + // it replaced a pending [U] restart from an earlier successful update + // with that same empty banner. + statusCmd := m.setStatus("update failed — see [3]") + m.recordUpdateFailure(msg) + return m, statusCmd + } + m.selfState = selfUpdated + statusCmd := m.setStatus("updated " + selfToolName) + // The new binary is on disk, but this process is still the old one — + // only a tracked keeptui has a card and a ↑ marker for the re-detect + // to update. + if t, tracked := m.toolByName(msg.tool); tracked { + return m, tea.Batch(statusCmd, fetchInstalledCmd(t)) + } + return m, statusCmd + } // A tool untracked mid-update is no longer in m.tools: just drop the // guard — no re-fetch, no statusMsg reaching into a card that is gone. t, ok := m.toolByName(msg.tool) @@ -717,24 +1074,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { statusCmd := m.setStatus("update failed — see [3]") - // A command that fails before emitting any output (empty argv, missing - // manager binary, StdoutPipe/Start error, immediate non-zero exit) - // leaves updateLog empty, so [3] would still read "starting update…" - // while the status bar points there for the reason. Seed the log with - // the error so [3] shows it. The argv never carries the token and - // msg.err is an exec/exit error, so this stays token-free. - if m.updateLogFor == msg.tool && len(m.updateLog) == 0 { - m.updateLog = append(m.updateLog, "update failed: "+msg.err.Error()) - if mt, ok := m.selectedMeta(); ok && mt.Name == msg.tool { - m.helpViewport.SetContent(m.renderHelpContent()) - m.helpViewport.GotoBottom() - } - } - // Record the failure for post-hoc research: manager, exit error and - // the tail of the log. The update argv never carries the token, and - // nothing here reads it, so the log stays token-free. - logx.Errorf("update failed: tool=%s manager=%s err=%v tail=%q", - msg.tool, m.updatePlan.Manager, msg.err, tailLines(m.updateLog, 5)) + m.recordUpdateFailure(msg) m.briefViewport.SetContent(m.renderCard()) return m, statusCmd } @@ -1213,16 +1553,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.focus == focusBrief { // Update the selected tool: only when it has a pending release // (else a hint) and no update is already running (one at a time, - // no queue). Detection spawns subprocesses, so it runs in a + // no queue — the refusal reports itself like every other blocked + // action here). Detection spawns subprocesses, so it runs in a // tea.Cmd — the updateDetectedMsg handler opens the confirm mode. if m.updatingFor != "" { - return m, nil + return m, m.setStatus(updateBusyStatus) } if t, ok := m.selectedTool(); ok { if !m.hasUpdate(t.Name) { return m, m.setStatus("no update available for " + t.Name) } - return m, detectUpdateCmd(t) + return m, detectUpdateCmd(t, false) } } @@ -1289,6 +1630,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // search/note/token field stays literal text. m.mode = modeHotkeys return m, nil + + case "U": + // The self-update banner's action key, live in every focus and in + // both the full banner and its collapsed cell. Reached only in + // modeNormal — like L, every input mode's handler returns earlier, + // so a capital U typed into a search/note/token field stays text. + return m, m.selfUpdateKey() + + case "X": + // Fold the banner into its compact right-group cell. Session-only — + // nothing is written to disk, and [U] stays reachable there. + switch m.selfState { + case selfOffered: + m.selfState = selfDismissed + case selfUpdated: + m.selfState = selfUpdatedLater + case selfNone, selfDismissed, selfUpdatedLater: + // Nothing to fold: no banner, or it is already the compact cell. + default: + logx.Errorf("self-update: [X] on unhandled selfState %d", m.selfState) + } + return m, nil } // No keyboard fall-through into viewport.Update: every scroll key is bound // explicitly above, and the default pager keymap was zeroed (see @@ -1559,6 +1922,10 @@ func (m Model) filteredMeta() []loader.ToolMeta { // cannot drift between call sites. func (m *Model) selectMeta(idx int) tea.Cmd { m.metaSelected = idx + // Moving to a tool is intent to see that tool: a completed self-update log + // would otherwise hold [3] for every row (it is selection-independent by + // design), with no way back to help. A live one keeps the panel. + m.dismissSelfLog() m.setToolsContent() m.briefViewport.GotoTop() m.briefViewport.SetContent(m.renderCard()) @@ -1597,10 +1964,12 @@ func (m *Model) setHelpContent() { m.helpEntries = nil m.helpNavIdx = -1 m.helpBase = "" - // The update log and the loading state render instead of help text; both - // leave the entry index empty so j/k stay plain scroll. A cache miss or - // stored "No --help output…" fallback yields no entries via the parser. - if mt, ok := m.selectedMeta(); ok && m.updateLogFor != mt.Name { + // The update log (a tool's or keeptui's own — showsUpdateLog covers both) and + // the loading state render instead of help text; both leave the entry index + // empty so j/k stay plain scroll instead of driving a spotlight over log + // lines. A cache miss or stored "No --help output…" fallback yields no + // entries via the parser. + if mt, ok := m.selectedMeta(); ok && !m.showsUpdateLog() { switch { case m.helpMode == helpModeReadme: // README mode: helpBase holds the glamour render (memoized — this @@ -1630,13 +1999,21 @@ func (m *Model) setHelpContent() { // caller: [h]/[m] fire from [2] as well and move focus, [r] is focusHelp-only. func (m *Model) switchHelpMode(mode int) tea.Cmd { m.helpMode = mode + // An explicit mode switch is intent to leave a completed update log + // (otherwise sticky), keeptui's own included — and that one is not tied to a + // selection, so it is released before the guard below, which returns early on + // an empty tracker. There the repaint has to happen here: nothing else + // re-renders [3] with no tool to select. + dropped := m.dismissSelfLog() mt, ok := m.selectedMeta() if !ok { + if dropped { + m.setHelpContent() + } return nil } - // An explicit mode switch is intent to leave a completed update log - // (otherwise sticky on re-selection); a live update (updatingFor == name) - // keeps the panel. + // The same intent for a tool's log; a live update (updatingFor == name) keeps + // the panel. if m.updateLogFor == mt.Name && m.updatingFor != mt.Name { m.updateLogFor = "" } diff --git a/internal/model/render.go b/internal/model/render.go index d296f9c..9b4e348 100644 --- a/internal/model/render.go +++ b/internal/model/render.go @@ -117,13 +117,13 @@ func (m Model) renderStatusBar() string { )) } if m.mode == modeConfirmUpdate { - name := "" - if mt, ok := m.selectedMeta(); ok { - name = mt.Name - } + // The name comes from the plan's own target, not the selection: for + // keeptui's own update the selection is a foreign tool — or nothing at + // all, with an empty tracker — and for a tool's it may have moved while + // detection ran. return style.Render(fmt.Sprintf( "%s %s run %s cancel", - ui.SearchPromptStyle.Render("update "+name+": "+m.updatePlan.Display), + ui.SearchPromptStyle.Render("update "+m.updateTarget+": "+m.updatePlan.Display), keyHint("enter"), keyHint("esc"), )) @@ -140,6 +140,13 @@ func (m Model) renderStatusBar() string { if m.statusMsg != "" { return style.Render(ui.SearchPromptStyle.Render(m.statusMsg)) } + // The self-update banner replaces the hint cells in all three normal focus + // states — one check here rather than one per focus branch, since those are + // the only paths left below. A transient statusMsg outranks it (branch + // above) and expires on its own after statusMsgTTL. + if cells, ok := m.selfBannerCells(); ok { + return m.renderHintsBar(style, cells) + } if m.focus == focusBrief { hints := []string{ keyHint("o") + " open repo", @@ -204,7 +211,8 @@ const rateGaugeMinGap = 2 // hintSep separates two hint cells in the status bar. const hintSep = " " -// renderHintsBar lays out the left-aligned hint cells with the API-usage gauge +// renderHintsBar lays out the left-aligned hint cells with the right group (the +// API-usage gauge plus, when there is one, the collapsed self-update cell) // pinned to the right corner. inner is HelpStyle's content width (m.width-2, the // border sits outside it). // @@ -213,33 +221,133 @@ const hintSep = " " // lines — one row past the terminal, which scrolls the top border off the alt // screen. Cells are therefore dropped from the right (they are ordered // most-important first) until the joined hints fit; the least useful reminders -// ([?] keys, [q] quit) are the first to go on a narrow terminal. Only then is -// the gauge placed, downgrading full → compact → hidden. +// ([?] keys, [q] quit) are the first to go on a narrow terminal. A collapsed +// self cell keeps dropping them: it is the only remaining surface of a pending +// update, so it outranks the trailing reminders as well as the gauge, and at 80 +// columns it would otherwise never fit at all. The leading cell is never +// dropped, and when even it is too wide (the fused banner cell on a very narrow +// terminal) it is truncated rather than allowed to wrap. +// +// Only then is the right group placed, degrading in a fixed order: full gauge + +// self cell → compact gauge + self cell → self cell alone → compact gauge. (The +// full gauge is not a step after the self cell: place() fails monotonically in +// width and the full gauge is always wider than the cell, so it could never fit +// where the cell did not.) func (m Model) renderHintsBar(style lipgloss.Style, cells []string) string { inner := m.width - 2 - for len(cells) > 1 && lipgloss.Width(strings.Join(cells, hintSep)) > inner { + joined := func(cs []string) int { return lipgloss.Width(strings.Join(cs, hintSep)) } + // The self cell is reserved for before any dropping starts: it lives in the + // right group and is never dropped itself, so the hints have to fit around + // it. One loop, one rule — a plain "fit the hints" pass ahead of it would be + // dead work whose predicate has to be kept in sync with this one by hand. + self := m.selfCompactCell() + reserve := 0 + if self != "" { + reserve = rateGaugeMinGap + lipgloss.Width(self) + } + for len(cells) > 1 && joined(cells)+reserve > inner { cells = cells[:len(cells)-1] } hints := strings.Join(cells, hintSep) - place := func(gauge string) (string, bool) { - if gauge == "" { + if lipgloss.Width(hints) > inner { + // One cell wider than the whole bar — the fused banner cell on a very + // narrow terminal. Styling is stripped before the cut: a cut landing + // inside an escape sequence would be re-emitted to the terminal verbatim. + hints = truncateToWidth(stripANSI(hints), max(inner, 1)) + } + place := func(right string) (string, bool) { + if right == "" { return "", false } - gap := inner - lipgloss.Width(hints) - lipgloss.Width(gauge) + gap := inner - lipgloss.Width(hints) - lipgloss.Width(right) if gap < rateGaugeMinGap { return "", false } - return hints + strings.Repeat(" ", gap) + gauge, true + return hints + strings.Repeat(" ", gap) + right, true + } + // withGauge pairs a gauge with the self cell, or yields "" when that gauge + // form is unavailable (no known rate snapshot) so place() skips the + // candidate instead of rendering a stray separator. + withGauge := func(gauge string) string { + if gauge == "" { + return "" + } + return gauge + hintSep + self } - if line, ok := place(m.renderRateGauge(false)); ok { - return style.Render(line) + full, compact := m.renderRateGauge(false), m.renderRateGauge(true) + candidates := []string{full, compact} + if self != "" { + candidates = []string{withGauge(full), withGauge(compact), self, compact} } - if line, ok := place(m.renderRateGauge(true)); ok { - return style.Render(line) + for _, c := range candidates { + if line, ok := place(c); ok { + return style.Render(line) + } } return style.Render(hints) } +// selfBannerCells returns the self-update banner as most-important-first hint +// cells (the shape renderHintsBar drops from the right) and whether it replaces +// the normal hints. The announcement and its main action are deliberately fused +// into one cell: a separate "[U] update" cell would be the first thing dropped +// on a narrow terminal, leaving an announcement with no way to act on it. +// +// While the self-update itself runs there is no banner — the compact +// "keeptui updating…" cell in the right group is the only in-flight indicator +// besides the live log in [3] (an untracked keeptui has no card, so the card +// spinner never fires), and the normal hints stay usable meanwhile. +func (m Model) selfBannerCells() ([]string, bool) { + if m.selfUpdating() { + return nil, false + } + switch m.selfState { + case selfOffered: + return []string{ + selfToolName + " " + ui.UpdateAvailableStyle.Render(m.selfLatest) + " available — " + keyHint("U") + " update", + keyHint("X") + " dismiss", + }, true + case selfUpdated: + return []string{ + selfToolName + " updated — " + keyHint("U") + " restart", + keyHint("X") + " later", + }, true + case selfNone, selfDismissed, selfUpdatedLater: + // No full banner: nothing was offered, or [X] folded it into the compact + // cell selfCompactCell renders instead. + default: + // Enumerated rather than left to fall through: a new selfState must be + // decided here, and TestSelfStateSitesAreExhaustive is what says so. + } + return nil, false +} + +// selfCompactCell returns the collapsed self-update cell for the status bar's +// right group, or "" when the banner is either absent or shown in full. It is +// what keeps [U] reachable for the rest of the session after [X], so the dismiss +// is a fold, not a cancel. +// +// The ↑ glyph is East-Asian Ambiguous (two cells under RUNEWIDTH_EASTASIAN=1), +// the same accepted class as the tool list's ⏺/↑ markers. renderHintsBar +// measures the cell with lipgloss.Width, so an over-wide measurement can only +// drop the cell earlier, never overflow the line. +func (m Model) selfCompactCell() string { + if m.selfUpdating() { + return selfToolName + " updating…" + } + switch m.selfState { + case selfDismissed: + return selfToolName + " " + ui.UpdateAvailableStyle.Render("↑") + " " + keyHint("U") + case selfUpdatedLater: + return selfToolName + " " + keyHint("U") + " restart" + case selfNone, selfOffered, selfUpdated: + // Nothing to collapse: no banner at all, or the full one is on screen. + default: + // See selfBannerCells: a new state has to be decided at every site. + } + return "" +} + func keyHint(k string) string { return ui.SearchPromptStyle.Render("[" + k + "]") } @@ -516,7 +624,17 @@ func (m Model) renderHotkeys() string { }}, }) - col2 := renderColumn([]hotkeyGroup{ + // Self lives in column 2 because it is the shortest one: column 1 is exactly + // at the 20-row budget, so a group there would clip. Descriptions must stay + // <= 12 cells (the column's current widest, "cycle status") or the column — + // and with it the overlay — grows past 76 columns. + // + // It is the one state-dependent group: U and X are bound only while a banner + // is on screen, and the two live states bind them differently (update/dismiss + // vs restart/later). Listing one fixed wording would document half the state + // machine and, on a build with no banner at all (any dev build), advertise two + // dead keys. + groups2 := []hotkeyGroup{ {"[2] Brief", []hotkeyRow{ {"o", "open repo"}, {"c", "changelog"}, @@ -528,7 +646,24 @@ func (m Model) renderHotkeys() string { {"h", "--help"}, {"m", "man page"}, }}, - }) + } + switch m.selfState { + case selfOffered, selfDismissed: + groups2 = append(groups2, hotkeyGroup{"Self", []hotkeyRow{ + {"U", "self-update"}, + {"X", "dismiss"}, + }}) + case selfUpdated, selfUpdatedLater: + groups2 = append(groups2, hotkeyGroup{"Self", []hotkeyRow{ + {"U", "restart"}, + {"X", "later"}, + }}) + case selfNone: + // No banner, so U/X are unbound and the group would document dead keys. + default: + // See selfBannerCells: a new state has to be decided at every site. + } + col2 := renderColumn(groups2) col3 := renderColumn([]hotkeyGroup{ {"[3] Help / Man / Readme", []hotkeyRow{ @@ -926,9 +1061,10 @@ func (m Model) renderHelp() string { case helpModeReadme: title = "[3] Readme" } - // While the selected tool's live update log is showing, the panel is the - // update log, not help — mirror that in the inset title. - if mt, ok := m.selectedMeta(); ok && m.updateLogFor != "" && m.updateLogFor == mt.Name { + // While an update log is showing — the selected tool's, or keeptui's own, + // which is selection-independent — the panel is the log, not help. Mirror + // that in the inset title. + if m.showsUpdateLog() { title = "[3] Update" } panel := panelStyle. @@ -1406,24 +1542,26 @@ func (m Model) readmeContent(name string) (string, bool) { } func (m Model) renderHelpContent() string { - mt, ok := m.selectedMeta() - if !ok { - return ui.MetaNoteStyle.Render("No tool selected") - } - - // Live update log: while the selected tool is (or was just) being updated, - // [3] shows the merged stdout+stderr buffer instead of help. This branch - // sits ahead of the helpLoadingFor/cache branches so re-selecting the - // updating tool never paints "Loading..." (autoFetchCmdsForSelected also - // skips the help fetch for this tool, so no late helpOutputMsg clobbers it). - // The buffer survives until the next update starts. - if m.updateLogFor != "" && m.updateLogFor == mt.Name { + // Live update log: while a tool is (or was just) being updated, [3] shows the + // merged stdout+stderr buffer instead of help. This branch sits ahead of the + // helpLoadingFor/cache branches so re-selecting the updating tool never paints + // "Loading..." (autoFetchCmdsForSelected also skips the help fetch, so no late + // helpOutputMsg clobbers it), and ahead of the "no tool selected" guard: a + // self-update's log is not tied to a selection at all — keeptui is typically + // untracked and the tracker may even be empty. The buffer survives until the + // next update starts. + if m.showsUpdateLog() { if len(m.updateLog) == 0 { return ui.MetaNoteStyle.Render("starting update…") } return wrapText(strings.Join(m.updateLog, "\n"), m.helpWrapWidth()) } + mt, ok := m.selectedMeta() + if !ok { + return ui.MetaNoteStyle.Render("No tool selected") + } + // README mode: content comes from readmeData (glamour-rendered into helpBase // by setHelpContent), never from the [2]string helpCache. Placed after the // update-log branch, which keeps priority, and ahead of every helpCache diff --git a/internal/model/render_test.go b/internal/model/render_test.go index 7a85998..fba964a 100644 --- a/internal/model/render_test.go +++ b/internal/model/render_test.go @@ -3292,26 +3292,30 @@ func TestHelpBaseCache(t *testing.T) { } // hotkeysViewModel builds a ready 80x24 model in modeHotkeys for View-level -// overlay assertions. -func hotkeysViewModel() Model { +// overlay assertions. The self state governs the one state-dependent group, so +// it is a parameter: selfNone lists no Self group at all (both keys are unbound +// there), and the two live states word it differently. +func hotkeysViewModel(state selfState) Model { m := New([]loader.ToolMeta{{Name: "git"}}) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = updated.(Model) m.mode = modeHotkeys + m.selfState = state return m } // TestRenderHotkeysOverlayContent: View in modeHotkeys shows every group header // and a per-group spot-check key/description. func TestRenderHotkeysOverlayContent(t *testing.T) { - m := hotkeysViewModel() + m := hotkeysViewModel(selfOffered) view := m.View() for _, want := range []string{ "Keyboard shortcuts", - "Global", "[1] Tools", "[2] Brief", "[3] Help / Man / Readme", "Scrolling", + "Global", "[1] Tools", "[2] Brief", "Self", "[3] Help / Man / Readme", "Scrolling", "focus panel", // Global "select tool", // [1] Tools "open repo", // [2] Brief + "self-update", // Self "entry nav", // [3] Help / Man / Readme "readme", // [3] the third panel source "3 lines", // Scrolling @@ -3324,12 +3328,43 @@ func TestRenderHotkeysOverlayContent(t *testing.T) { } } +// TestRenderHotkeysSelfGroupFollowsState: the Self group documents the bindings +// that are actually live. With no banner (every dev build) U and X do nothing and +// the group is absent; after a successful update they mean restart/later, not +// update/dismiss — one fixed wording would document half the state machine. +func TestRenderHotkeysSelfGroupFollowsState(t *testing.T) { + tests := []struct { + state selfState + want []string + absent []string + }{ + {state: selfNone, absent: []string{"Self", "self-update", "restart"}}, + {state: selfOffered, want: []string{"Self", "self-update", "dismiss"}, absent: []string{"restart"}}, + {state: selfDismissed, want: []string{"Self", "self-update", "dismiss"}}, + {state: selfUpdated, want: []string{"Self", "restart", "later"}, absent: []string{"self-update"}}, + {state: selfUpdatedLater, want: []string{"Self", "restart", "later"}}, + } + for _, tt := range tests { + view := hotkeysViewModel(tt.state).renderHotkeys() + for _, want := range tt.want { + if !strings.Contains(view, want) { + t.Errorf("state %v: overlay missing %q", tt.state, want) + } + } + for _, absent := range tt.absent { + if strings.Contains(view, absent) { + t.Errorf("state %v: overlay should not contain %q", tt.state, absent) + } + } + } +} + // TestRenderHotkeysDimsBackground: the overlay dims the composited background, // mirroring the [L] overlay. func TestRenderHotkeysDimsBackground(t *testing.T) { forceColorProfile(t) const dimSeq = "38;2;136;136;136" // ui.ColorDim #888888 - m := hotkeysViewModel() + m := hotkeysViewModel(selfNone) if !strings.Contains(m.View(), dimSeq) { t.Errorf("hotkeys overlay did not dim the background") } @@ -3339,7 +3374,9 @@ func TestRenderHotkeysDimsBackground(t *testing.T) { // with no PlaceOverlay clipping — both the top title's close hint and the // bottom Scrolling row survive. func TestRenderHotkeysSizeBudget(t *testing.T) { - m := hotkeysViewModel() + // selfOffered is the widest variant of the one state-dependent group + // ("self-update", 11 cells against the column's 12-cell "cycle status"). + m := hotkeysViewModel(selfOffered) view := m.View() // Title row (top of the overlay). if !strings.Contains(view, "close") { @@ -3355,12 +3392,20 @@ func TestRenderHotkeysSizeBudget(t *testing.T) { if !strings.Contains(view, "readme") { t.Errorf("size budget: [3] readme row clipped") } - // The framed overlay must be <= the 20-row background height. - if h := lipgloss.Height(m.renderHotkeys()); h > 20 { - t.Errorf("overlay framed height = %d, want <= 20 (80x24 background)", h) + // Last row of the Self group — the newest addition, in the shortest column. + if !strings.Contains(view, "dismiss") { + t.Errorf("size budget: Self group's last row clipped") } - if w := lipgloss.Width(m.renderHotkeys()); w > 76 { - t.Errorf("overlay framed width = %d, want <= 76", w) + // The framed overlay must be <= the 20-row background height — in every self + // state, since the Self group's wording (and presence) varies with it. + for _, state := range []selfState{selfNone, selfOffered, selfDismissed, selfUpdated, selfUpdatedLater} { + overlay := hotkeysViewModel(state).renderHotkeys() + if h := lipgloss.Height(overlay); h > 20 { + t.Errorf("state %v: overlay framed height = %d, want <= 20 (80x24 background)", state, h) + } + if w := lipgloss.Width(overlay); w > 76 { + t.Errorf("state %v: overlay framed width = %d, want <= 76", state, w) + } } } @@ -3591,35 +3636,97 @@ func maxLineWidth(s string) int { // returns more rows than the terminal has (an over-tall View scrolls the top // border off the alt screen). renderHintsBar drops trailing hint cells to get // there; only the presence of a wrap is a bug. +// The self-update cases additionally seed a known rate snapshot: without one the +// gauge is not drawn at all and the right group's geometry — where the banner's +// compact cell competes with it for the corner — would go unchecked. func TestStatusBarNeverWraps(t *testing.T) { cases := []struct { - name string - focus int - helpMode int - nav bool + name string + focus int + helpMode int + nav bool + self selfState + selfTag string + updating bool + knownRate bool + width int }{ - {"tools", focusTools, helpModeHelp, false}, - {"brief", focusBrief, helpModeHelp, false}, - {"help mode", focusHelp, helpModeHelp, false}, - {"man mode", focusHelp, helpModeMan, false}, - {"readme mode", focusHelp, helpModeReadme, false}, - {"help mode with entry nav", focusHelp, helpModeHelp, true}, + {name: "tools", focus: focusTools, helpMode: helpModeHelp}, + {name: "brief", focus: focusBrief, helpMode: helpModeHelp}, + {name: "help mode", focus: focusHelp, helpMode: helpModeHelp}, + {name: "man mode", focus: focusHelp, helpMode: helpModeMan}, + {name: "readme mode", focus: focusHelp, helpMode: helpModeReadme}, + {name: "help mode with entry nav", focus: focusHelp, helpMode: helpModeHelp, nav: true}, + {name: "self offered banner", focus: focusTools, helpMode: helpModeHelp, self: selfOffered, knownRate: true}, + {name: "self offered banner in brief", focus: focusBrief, helpMode: helpModeHelp, self: selfOffered, knownRate: true}, + {name: "self dismissed cell", focus: focusTools, helpMode: helpModeHelp, self: selfDismissed, knownRate: true}, + {name: "self dismissed cell in help", focus: focusHelp, helpMode: helpModeReadme, self: selfDismissed, knownRate: true}, + {name: "self updated banner", focus: focusTools, helpMode: helpModeHelp, self: selfUpdated, knownRate: true}, + {name: "self restart later cell", focus: focusBrief, helpMode: helpModeHelp, self: selfUpdatedLater, knownRate: true}, + {name: "self updating cell", focus: focusTools, helpMode: helpModeHelp, self: selfOffered, updating: true, knownRate: true}, + // Narrow terminals: the fused banner cell is undroppable (a dropped [U] + // would leave an announcement with no way to act on it), so below its own + // width it must be cut rather than allowed to wrap. 40 is around the + // threshold for the default tag, 36 below it, and a long upstream tag + // pushes the same cell over the edge at a comfortable width. + {name: "self banner at 40 cols", focus: focusTools, helpMode: helpModeHelp, self: selfOffered, width: 40}, + {name: "self banner at 36 cols", focus: focusTools, helpMode: helpModeHelp, self: selfOffered, width: 36}, + {name: "self banner at 24 cols", focus: focusBrief, helpMode: helpModeHelp, self: selfUpdated, width: 24}, + { + name: "long tag in the banner", focus: focusTools, helpMode: helpModeHelp, + self: selfOffered, selfTag: "v2026.07.25-nightly.1+build.42", width: 44, knownRate: true, + }, + // The collapsed cell has to fit the 80x24 baseline: after [X] it is the + // feature's only visible surface for the rest of the session, so it drops + // hint cells rather than itself. + {name: "self dismissed cell at 80 cols", focus: focusTools, helpMode: helpModeHelp, self: selfDismissed, knownRate: true, width: 80}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Setenv("HOME", t.TempDir()) - m := New([]loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}}) - m = mustModel(m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})) + width := tc.width + if width == 0 { + width = 80 + } + // WithAppVersion is what main injects and what selfCheckEnabled gates + // the whole feature on. Without it selfUpdating() is false whatever + // updatingFor says, so the updating case below would silently render the + // offered banner instead of the compact "keeptui updating…" cell. + m := New([]loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}}).WithAppVersion("v0.4.2") + m = mustModel(m.Update(tea.WindowSizeMsg{Width: width, Height: 24})) m.focus = tc.focus m.helpMode = tc.helpMode if tc.nav { m.helpEntries = []entryRange{{start: 0, end: 1}} m.helpNavIdx = 0 } + m.selfState = tc.self + m.selfLatest = "v0.5.0" + if tc.selfTag != "" { + m.selfLatest = tc.selfTag + } + if tc.updating { + m.updatingFor = selfToolName + m.selfUpdateLog = true + if !m.selfUpdating() { + t.Fatal("fixture: the model must be mid-self-update, " + + "else this case renders the offered banner and duplicates the case above") + } + } + if tc.knownRate { + m.rate = version.RateLimit{Known: true, Limit: 60, Remaining: 42} + } if got := lipgloss.Height(m.renderStatusBar()); got != 3 { t.Errorf("status bar height = %d, want 3 (border + one hint line)", got) } + // Only at the baseline width and above: the three panels have their + // own minimum widths (15+30+30 plus borders), so below ~80 columns the + // layout overflows on its own — with or without a banner — and that is + // a separate, pre-existing limit, not the bar wrapping. + if width < 80 { + return + } if got := lipgloss.Height(m.View()); got > m.height { t.Errorf("View() height = %d, want at most the terminal height %d", got, m.height) } diff --git a/internal/model/selfupdate_test.go b/internal/model/selfupdate_test.go new file mode 100644 index 0000000..413f461 --- /dev/null +++ b/internal/model/selfupdate_test.go @@ -0,0 +1,1518 @@ +package model + +import ( + "errors" + "strconv" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keeptui/internal/updater" + "github.com/stanlyzoolo/keeptui/internal/version" +) + +// ---- fixtures ---- + +// selfTestVersion is the running build every self-update test claims: a real +// release tag, so selfCheckEnabled() is on and the feature exists at all. +// selfTestLatest is the newer release the banner offers. +const ( + selfTestVersion = "v0.4.2" + selfTestLatest = "v0.5.0" +) + +// selfModel is the one fixture of this file: a ready model on a release build +// with the given tracker, terminal width and banner state. The three axes used to +// be frozen in two separate helpers that differed only in which ones they let a +// test pick. +// +// HOME is redirected as well. TestMain's package-wide seams already keep +// meta.yaml, cache.json, the token and the logs off the real config, so this is +// only insurance for anything else that resolves a home directory (updater's go +// bin path, version's brew fallback) — but it is applied in exactly one place +// now, instead of at three of six former fixture sites. +func selfModel(t *testing.T, meta []loader.ToolMeta, width int, state selfState) Model { + t.Helper() + t.Setenv("HOME", t.TempDir()) + m := New(meta).WithAppVersion(selfTestVersion) + m = mustModel(m.Update(tea.WindowSizeMsg{Width: width, Height: 24})) + m.selfState = state + m.selfLatest = selfTestLatest + return m +} + +// selfBarTools is the tracker the status-bar tests run against: exactly one +// tool, deliberately not named keeptui, so every "keeptui" in a rendered bar can +// only have come from the self-update banner. +func selfBarTools() []loader.ToolMeta { + return []loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}} +} + +// startedSelfUpdate is selfModel with a self-update already streaming: the +// pipeline state the confirm dialog's enter leaves behind. The banner state is a +// parameter rather than an inherited selfOffered — a completion test that asserts +// the state is untouched must be able to name the state it started from. +func startedSelfUpdate(t *testing.T, meta []loader.ToolMeta, state selfState) Model { + t.Helper() + m := selfModel(t, meta, 100, state) + m.updatingFor = selfToolName + m.updateLogFor = selfToolName + m.selfUpdateLog = true + return m +} + +// seedSelfReleaseCache adds a fresh release-only cache entry for keeptui's own +// repo. It is what lets selfCheckCmd actually be executed in this package: +// version.SelfLatest answers from cache.json without a request, and version's +// httptest seam (testAPIBase) is unexported there. +// +// The whole cache is snapshotted and restored, so the entry does not leak into +// the rest of the package (cache.json is shared package-wide by TestMain's +// version.SetConfigDirForTesting) and a co-existing README seed survives. +func seedSelfReleaseCache(t *testing.T, tag string) { + t.Helper() + before := version.LoadCache() + restore := make(version.Cache, len(before)) + for k, v := range before { + restore[k] = v + } + t.Cleanup(func() { version.SaveCache(restore) }) + + next := make(version.Cache, len(before)+1) + for k, v := range before { + next[k] = v + } + entry := next[version.SelfRepo] + entry.Latest = tag + entry.ReleaseCheckedAt = time.Now() + next[version.SelfRepo] = entry + version.SaveCache(next) +} + +// ---- the version gate and the startup check ---- + +// TestSelfCheckEnabled: only a real release version turns the self-check on. The +// pseudo-version rows are the load-bearing ones: `go build .` / `go install .` +// stamp the module version from VCS since Go 1.24, so a working copy reports a +// pseudo-version (and +dirty with uncommitted changes) rather than "dev" — and +// those canonicalize as pre-releases sorting below every real tag, so a missing +// check makes a developer's own build offer to overwrite itself. +func TestSelfCheckEnabled(t *testing.T) { + tests := []struct { + name string + version string + want bool + }{ + {"unset", "", false}, + {"dev build", "dev", false}, + {"release tag", "v0.4.2", true}, + {"bare numeric", "0.4.2", true}, + {"pseudo-version from a tagless checkout", "v0.0.0-20260725115912-1be4bafa79c8", false}, + {"pseudo-version ahead of a tag", "v0.4.3-0.20260725115912-1be4bafa79c8", false}, + {"pseudo-version above a pre-release", "v0.4.3-rc.1.0.20260725115912-1be4bafa79c8", false}, + {"dirty working copy", "v0.0.0-20260725115912-1be4bafa79c8+dirty", false}, + {"dirty release tag", "v0.4.2+dirty", false}, + {"release tag with a pre-release", "v0.5.0-rc.1", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(nil).WithAppVersion(tt.version) + if got := m.selfCheckEnabled(); got != tt.want { + t.Errorf("selfCheckEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestInitSelfCheckGatedOnVersion: Init queues the self-check only on a build +// with a real version — and the extra element really is the self-check, which is +// verified by executing it against a warm cache (a live request would be both +// flaky and rude; version's httptest seam is unexported from that package). +func TestInitSelfCheckGatedOnVersion(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + seedSelfReleaseCache(t, "v9.9.9") + + batch := func(ver string) tea.BatchMsg { + m := New([]loader.ToolMeta{{Name: "localtool"}}).WithAppVersion(ver) + m.width, m.height = 80, 24 + msgs, ok := m.Init()().(tea.BatchMsg) + if !ok { + t.Fatal("Init must batch several commands") + } + return msgs + } + + base := len(batch("")) + for _, ver := range []string{"dev", "v0.0.0-20260725115912-1be4bafa79c8"} { + if got := len(batch(ver)); got != base { + t.Errorf("Init queued %d cmds on version %q and %d with no version, want no self-check", got, ver, base) + } + } + + release := batch("v0.4.2") + if len(release) != base+1 { + t.Fatalf("Init queued %d cmds on a release build and %d with no version, want exactly one more", len(release), base) + } + // The self-check rides right after the rate seed, i.e. at index 1. + msg, ok := release[1]().(selfCheckMsg) + if !ok { + t.Fatalf("Init batch element 1 produced %T, want the selfCheckMsg", release[1]()) + } + if msg.err != nil || msg.latest != "v9.9.9" { + t.Errorf("selfCheckMsg = %+v, want the cached tag with no error", msg) + } +} + +// TestInitSelfCheckIsNotLast: the README seed has to stay the last element of +// the batch (TestInitFetchesReadmeForSelected executes it), so the self-check +// rides right after the rate seed at the front. +func TestInitSelfCheckIsNotLast(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + seedReadmeCache(t, "BurntSushi/ripgrep", "# ripgrep") + + m := New([]loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}}).WithAppVersion("v0.4.2") + m.width, m.height = 80, 24 + + batch, ok := m.Init()().(tea.BatchMsg) + if !ok { + t.Fatal("Init must batch several commands") + } + if _, ok := batch[len(batch)-1]().(readmeMsg); !ok { + t.Error("last Init command is no longer the README seed — the self-check must not be appended last") + } +} + +// TestSelfCheckCmdServesCache: the command itself (not just Init's batch shape) +// round-trips a cached tag into a selfCheckMsg with no error — the nil-error path +// is also the one that must not write a session log. +func TestSelfCheckCmdServesCache(t *testing.T) { + seedSelfReleaseCache(t, "v9.9.9") + stamp := version.LoadCache()[version.SelfRepo].ReleaseCheckedAt + + msg, ok := selfCheckCmd()().(selfCheckMsg) + if !ok { + t.Fatalf("selfCheckCmd produced %T, want selfCheckMsg", msg) + } + if msg.err != nil { + t.Fatalf("selfCheckMsg.err = %v, want nil from a warm cache", msg.err) + } + if msg.latest != "v9.9.9" { + t.Errorf("selfCheckMsg.latest = %q, want v9.9.9", msg.latest) + } + // Hermeticity: any request would have re-stamped the entry (a conclusive + // answer) — the seed must have answered on its own, with no network access. + if got := version.LoadCache()[version.SelfRepo].ReleaseCheckedAt; !got.Equal(stamp) { + t.Errorf("ReleaseCheckedAt = %v, want the seed's %v (the cache must answer without a request)", got, stamp) + } + + // End to end through the handler: a newer tag than the running build raises + // the offer with that tag. + m := New([]loader.ToolMeta{{Name: "rg"}}).WithAppVersion("v0.4.2") + m.width, m.height = 80, 24 + m = mustModel(m.Update(msg)) + if m.selfState != selfOffered || m.selfLatest != "v9.9.9" { + t.Errorf("selfState = %v, selfLatest = %q, want the offer for v9.9.9", m.selfState, m.selfLatest) + } +} + +// TestSelfCheckMsgStates: the handler is the only place the running binary's +// version is compared against the release tag, and only a strictly newer tag +// raises the banner. +func TestSelfCheckMsgStates(t *testing.T) { + tests := []struct { + name string + appVersion string + msg selfCheckMsg + wantState selfState + wantLatest string + }{ + { + name: "newer release offers the update", + appVersion: "v0.4.2", + msg: selfCheckMsg{latest: "v0.5.0"}, + wantState: selfOffered, + wantLatest: "v0.5.0", + }, + { + name: "same version stays quiet", + appVersion: "v0.5.0", + msg: selfCheckMsg{latest: "v0.5.0"}, + wantState: selfNone, + }, + { + name: "older release stays quiet", + appVersion: "v0.6.0", + msg: selfCheckMsg{latest: "v0.5.0"}, + wantState: selfNone, + }, + { + name: "unparsable tag stays quiet", + appVersion: "v0.4.2", + msg: selfCheckMsg{latest: "nightly"}, + wantState: selfNone, + }, + { + name: "unparsable running version stays quiet", + appVersion: "some-build", + msg: selfCheckMsg{latest: "v0.5.0"}, + wantState: selfNone, + }, + { + name: "no release published stays quiet", + appVersion: "v0.4.2", + msg: selfCheckMsg{}, + wantState: selfNone, + }, + { + name: "rate limit stays quiet", + appVersion: "v0.4.2", + msg: selfCheckMsg{err: version.ErrRateLimited}, + wantState: selfNone, + }, + { + // The error alone must disqualify the message: a tag arriving + // alongside one is not an answer this build can act on. + name: "a tag with an error stays quiet", + appVersion: "v0.4.2", + msg: selfCheckMsg{latest: "v9.9.9", err: version.ErrRateLimited}, + wantState: selfNone, + }, + { + name: "transient failure stays quiet", + appVersion: "v0.4.2", + msg: selfCheckMsg{err: errors.New("dial tcp: no route to host")}, + wantState: selfNone, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New([]loader.ToolMeta{{Name: "localtool"}}).WithAppVersion(tt.appVersion) + m.width, m.height = 80, 24 + + m = mustModel(m.Update(tt.msg)) + if m.selfState != tt.wantState { + t.Errorf("selfState = %v, want %v", m.selfState, tt.wantState) + } + if m.selfLatest != tt.wantLatest { + t.Errorf("selfLatest = %q, want %q", m.selfLatest, tt.wantLatest) + } + }) + } +} + +// TestSelfCheckMsgKeepsActedOnState: the message arrives once, but the handler +// must not be a way for it to walk back a state the user already acted on — and +// that holds for a *newer* tag too, which is the answer that actually writes. +func TestSelfCheckMsgKeepsActedOnState(t *testing.T) { + for _, state := range []selfState{selfDismissed, selfUpdated, selfUpdatedLater} { + for _, msg := range []selfCheckMsg{{latest: "v0.4.2"}, {latest: "v9.9.9"}} { + m := New([]loader.ToolMeta{{Name: "localtool"}}).WithAppVersion("v0.4.2") + m.width, m.height = 80, 24 + m.selfState = state + m.selfLatest = "v0.5.0" + + m = mustModel(m.Update(msg)) + if m.selfState != state { + t.Errorf("latest %q: selfState = %v, want %v to survive", msg.latest, m.selfState, state) + } + if m.selfLatest != "v0.5.0" { + t.Errorf("latest %q: selfLatest = %q, want the acted-on tag kept", msg.latest, m.selfLatest) + } + } + } +} + +// ---- the selfState machine and what it renders ---- + +// TestSelfStateSitesAreExhaustive walks every selfState through the five sites +// that switch on it. .golangci.yml carries no exhaustiveness linter, so a sixth +// member would otherwise compile with some of those sites silently missing it — +// the row-count check against selfStateCount is what makes adding one fail here +// first, and the per-site columns say which sites it has to be decided at. +func TestSelfStateSitesAreExhaustive(t *testing.T) { + tests := []struct { + state selfState + wantBanner bool // selfBannerCells replaces the hints with a full banner + wantCompact bool // selfCompactCell renders the folded right-group cell + wantHotkeys bool // the [?] overlay carries a Self group + wantU bool // [U] is bound (a command, or the restart flag) + }{ + {state: selfNone}, + {state: selfOffered, wantBanner: true, wantHotkeys: true, wantU: true}, + {state: selfDismissed, wantCompact: true, wantHotkeys: true, wantU: true}, + {state: selfUpdated, wantBanner: true, wantHotkeys: true, wantU: true}, + {state: selfUpdatedLater, wantCompact: true, wantHotkeys: true, wantU: true}, + } + if len(tests) != int(selfStateCount) { + t.Fatalf("table has %d rows, want one per selfState (%d): a new state must be decided at all five sites", + len(tests), selfStateCount) + } + + for _, tt := range tests { + t.Run(strconv.Itoa(int(tt.state)), func(t *testing.T) { + m := selfModel(t, selfBarTools(), 100, tt.state) + if _, full := m.selfBannerCells(); full != tt.wantBanner { + t.Errorf("selfBannerCells full = %v, want %v", full, tt.wantBanner) + } + if got := m.selfCompactCell() != ""; got != tt.wantCompact { + t.Errorf("selfCompactCell present = %v, want %v", got, tt.wantCompact) + } + hk := mustModel(m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})) + if got := strings.Contains(hk.renderHotkeys(), "Self"); got != tt.wantHotkeys { + t.Errorf("[?] Self group present = %v, want %v", got, tt.wantHotkeys) + } + u := m + cmd := u.selfUpdateKey() + if got := cmd != nil || u.restartRequested; got != tt.wantU { + t.Errorf("[U] bound = %v, want %v", got, tt.wantU) + } + // [X] has no per-state requirement — it is a legitimate no-op at three + // of the five — but it must never panic or invent a state. + x := mustModel(m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}})) + if x.selfState >= selfStateCount { + t.Errorf("[X] left selfState = %d, outside the enum", x.selfState) + } + }) + } +} + +// TestSelfUpdatingPredicate: "a self-update is in flight" is derived from the +// update pipeline's own state, not a selfState member, and every update of +// keeptui counts whichever key started it — so the target name decides which kind +// of update it is, and the version gate decides whether the kind exists at all: on +// a build with the feature off an update of keeptui is a plain tool update. +func TestSelfUpdatingPredicate(t *testing.T) { + tests := []struct { + name string + appVersion string + updatingFor string + want bool + }{ + {"idle", "v0.4.2", "", false}, + {"self update running", "v0.4.2", selfToolName, true}, + {"another tool updating", "v0.4.2", "rg", false}, + {"keeptui updating on a dev build", "dev", selfToolName, false}, + {"keeptui updating on a pseudo-version build", "v0.0.0-20260725115912-1be4bafa79c8", selfToolName, false}, + {"keeptui updating with no version injected", "", selfToolName, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(nil).WithAppVersion(tt.appVersion) + m.updatingFor = tt.updatingFor + if got := m.selfUpdating(); got != tt.want { + t.Errorf("selfUpdating() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestSelfBannerInStatusBar walks the state machine's rendered surfaces: the two +// full banners replace the hint cells, the two collapsed states keep the hints +// and add a compact cell, and a running self-update shows neither banner (the +// hints stay usable while the log streams into [3]). +func TestSelfBannerInStatusBar(t *testing.T) { + tests := []struct { + name string + state selfState + updating bool + want []string + absent []string + // wantCell is the expected selfCompactCell(): empty while a full banner + // is up, so the bar can never advertise [U] twice. + wantCell string + }{ + { + name: "no banner", + state: selfNone, + want: []string{"[t] track"}, + absent: []string{"keeptui"}, + }, + { + name: "offer replaces the hints", + state: selfOffered, + want: []string{"keeptui v0.5.0 available", "[U] update", "[X] dismiss"}, + absent: []string{"[t] track"}, + }, + { + name: "dismissed keeps the hints and folds to a cell", + state: selfDismissed, + want: []string{"[t] track", "keeptui ↑ [U]"}, + absent: []string{"available", "dismiss"}, + wantCell: "keeptui ↑ [U]", + }, + { + name: "updated offers the restart", + state: selfUpdated, + want: []string{"keeptui updated", "[U] restart", "[X] later"}, + absent: []string{"[t] track", "available"}, + }, + { + name: "restart later folds to a cell", + state: selfUpdatedLater, + want: []string{"[t] track", "keeptui [U] restart"}, + absent: []string{"later", "available"}, + wantCell: "keeptui [U] restart", + }, + { + name: "in flight shows neither banner", + state: selfOffered, + updating: true, + want: []string{"[t] track", "keeptui updating…"}, + absent: []string{"available", "[X] dismiss"}, + wantCell: "keeptui updating…", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selfModel(t, selfBarTools(), 120, tt.state) + if tt.updating { + m.updatingFor = selfToolName + m.selfUpdateLog = true + } + if got := stripANSI(m.selfCompactCell()); got != tt.wantCell { + t.Errorf("selfCompactCell() = %q, want %q", got, tt.wantCell) + } + bar := stripANSI(m.renderStatusBar()) + for _, want := range tt.want { + if !strings.Contains(bar, want) { + t.Errorf("status bar = %q, missing %q", bar, want) + } + } + for _, absent := range tt.absent { + if strings.Contains(bar, absent) { + t.Errorf("status bar = %q, should not contain %q", bar, absent) + } + } + }) + } +} + +// TestSelfBannerInEveryNormalFocus: the banner is a global announcement, not a +// panel action, so it replaces the hints in all three normal focus states. +func TestSelfBannerInEveryNormalFocus(t *testing.T) { + for _, focus := range []int{focusTools, focusBrief, focusHelp} { + m := selfModel(t, selfBarTools(), 120, selfOffered) + m.focus = focus + bar := stripANSI(m.renderStatusBar()) + if !strings.Contains(bar, "keeptui v0.5.0 available") || !strings.Contains(bar, "[U] update") { + t.Errorf("focus %d bar = %q, want the self-update banner", focus, bar) + } + } +} + +// TestSelfBannerYieldsToStatusMsg: the branch order is statusMsg > banner > +// hints. A transient status expires on its own (statusMsgTTL), so the banner +// comes back without any bookkeeping. +func TestSelfBannerYieldsToStatusMsg(t *testing.T) { + m := selfModel(t, selfBarTools(), 120, selfOffered) + m.statusMsg = "no repo for rg" + bar := stripANSI(m.renderStatusBar()) + if !strings.Contains(bar, "no repo for rg") { + t.Errorf("status bar = %q, want the transient message", bar) + } + if strings.Contains(bar, "keeptui") { + t.Errorf("status bar = %q, want the banner suppressed under a status message", bar) + } +} + +// TestSelfCompactCellRightGroupDegradation: the compact cell and the gauge share +// the right corner. Wide enough for both, they both render; when they no longer +// fit together the actionable cell is the one that stays. +func TestSelfCompactCellRightGroupDegradation(t *testing.T) { + rate := version.RateLimit{Known: true, Limit: 60, Remaining: 42} + + wide := selfModel(t, selfBarTools(), 160, selfDismissed) + wide.rate = rate + bar := stripANSI(wide.renderStatusBar()) + if !strings.Contains(bar, "GitHub API Usage") || !strings.Contains(bar, "keeptui ↑ [U]") { + t.Errorf("wide status bar = %q, want the full gauge and the self cell", bar) + } + + tight := selfModel(t, selfBarTools(), 100, selfDismissed) + tight.rate = rate + bar = stripANSI(tight.renderStatusBar()) + if !strings.Contains(bar, "keeptui ↑ [U]") { + t.Errorf("tight status bar = %q, want the self cell to outrank the gauge", bar) + } + if strings.Contains(bar, "GitHub API Usage") || strings.Contains(bar, "GH 18/60") { + t.Errorf("tight status bar = %q, want no gauge next to the self cell", bar) + } +} + +// TestSelfCompactCellFitsBaselineTerminal: at the 80x24 baseline the hints alone +// already fill the bar, so the collapsed cell only appears if it outranks the +// trailing reminders — and it has to: after [X] it is the feature's one visible +// surface for the rest of the session, and the gauge it displaces is read-only. +func TestSelfCompactCellFitsBaselineTerminal(t *testing.T) { + for _, focus := range []int{focusTools, focusBrief, focusHelp} { + m := selfModel(t, selfBarTools(), 80, selfDismissed) + m.focus = focus + m.rate = version.RateLimit{Known: true, Limit: 60, Remaining: 42} + + bar := stripANSI(m.renderStatusBar()) + if !strings.Contains(bar, "keeptui ↑ [U]") { + t.Errorf("focus %d: 80-col bar = %q, want the collapsed self cell", focus, bar) + } + // A dropped hint cell is the price; the leading one must survive. + if lines := strings.Count(bar, "\n"); lines > 2 { + t.Errorf("focus %d: bar wrapped to %d lines: %q", focus, lines+1, bar) + } + } +} + +// TestRateGaugeUnaffectedWithoutSelfCell: with no banner the right group is the +// gauge alone and its full → compact → hidden degradation is unchanged. +func TestRateGaugeUnaffectedWithoutSelfCell(t *testing.T) { + m := selfModel(t, selfBarTools(), 160, selfNone) + m.rate = version.RateLimit{Known: true, Limit: 60, Remaining: 42} + if bar := stripANSI(m.renderStatusBar()); !strings.Contains(bar, "GitHub API Usage") { + t.Errorf("status bar = %q, want the full gauge", bar) + } + + m = selfModel(t, selfBarTools(), 100, selfNone) + m.rate = version.RateLimit{Known: true, Limit: 60, Remaining: 42} + if bar := stripANSI(m.renderStatusBar()); !strings.Contains(bar, "GH 18/60") { + t.Errorf("status bar = %q, want the compact gauge", bar) + } +} + +// ---- the [U] / [X] keys ---- + +// TestSelfKeys is the single (state, key) → (next state, command, restart flag) +// table for the banner's two action keys. [X] folds a banner into its compact +// cell and is a no-op with nothing to fold; [U] never moves the state itself — +// the update pipeline and the restart do, so pressing it again is a retry — and +// it is the only key that may raise the restart flag main re-execs on, which is +// why the plain quit key is a row here too. +func TestSelfKeys(t *testing.T) { + // The command [U] returns, by kind. A detect command is deliberately not + // executed: driving updater.Detect would spawn subprocesses and resolve the + // developer's own keeptui — TestSelfUpdateKeyDetectsSelf executes it under an + // update_cmd override instead. + const ( + noCmd = "" + detect = "detect" + quit = "quit" + ) + tests := []struct { + name string + key string + state selfState + wantState selfState + wantCmd string + wantRestart bool + }{ + {name: "X folds the offer", key: "X", state: selfOffered, wantState: selfDismissed}, + {name: "X defers the restart", key: "X", state: selfUpdated, wantState: selfUpdatedLater}, + {name: "X with no banner", key: "X", state: selfNone, wantState: selfNone}, + {name: "X on the folded offer", key: "X", state: selfDismissed, wantState: selfDismissed}, + {name: "X on the folded restart", key: "X", state: selfUpdatedLater, wantState: selfUpdatedLater}, + {name: "U with no banner", key: "U", state: selfNone, wantState: selfNone}, + {name: "U acts on the offer", key: "U", state: selfOffered, wantState: selfOffered, wantCmd: detect}, + {name: "U acts on the folded offer", key: "U", state: selfDismissed, wantState: selfDismissed, wantCmd: detect}, + {name: "U restarts", key: "U", state: selfUpdated, wantState: selfUpdated, wantCmd: quit, wantRestart: true}, + { + name: "U restarts from the folded cell", key: "U", state: selfUpdatedLater, + wantState: selfUpdatedLater, wantCmd: quit, wantRestart: true, + }, + // The ordinary quit key quits without ever making main re-exec. + {name: "q after a restart offer", key: "q", state: selfUpdated, wantState: selfUpdated, wantCmd: quit}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selfModel(t, selfBarTools(), 120, tt.state) + if m.RestartRequested() { + t.Fatal("RestartRequested() = true before the key") + } + updated, cmd := m.Update(keyRunes(tt.key)) + m = updated.(Model) + if m.selfState != tt.wantState { + t.Errorf("selfState = %v, want %v", m.selfState, tt.wantState) + } + if got := m.RestartRequested(); got != tt.wantRestart { + t.Errorf("RestartRequested() = %v, want %v", got, tt.wantRestart) + } + switch tt.wantCmd { + case noCmd: + if cmd != nil { + t.Errorf("cmd produced %T, want no command", cmd()) + } + case quit: + if cmd == nil { + t.Fatal("cmd = nil, want tea.Quit") + } + if _, isQuit := cmd().(tea.QuitMsg); !isQuit { + t.Errorf("cmd produced %T, want tea.QuitMsg", cmd()) + } + case detect: + if cmd == nil { + t.Fatal("cmd = nil, want the self detection") + } + } + }) + } +} + +// TestSelfUpdateKeyDetectsSelf executes the command [U] returns: it must be a +// *self*-tagged detection of keeptui. Without this the two mutations that kill +// the feature's main case both survive — a detection of the selected tool, or one +// missing the self flag, is dropped by acceptsUpdateDetect's selection check +// whenever keeptui is untracked, leaving [U] silently dead forever. +// +// Both halves keep updater.Detect subprocess-free: an update_cmd short-circuits +// detection entirely, and a name that cannot be on PATH answers from the LookPath +// miss with ErrUnknownManager. +func TestSelfUpdateKeyDetectsSelf(t *testing.T) { + m := selfModel(t, selfBarTools(), 120, selfOffered) + m.meta = []loader.ToolMeta{{Name: selfToolName, UpdateCmd: "true"}} + m.tools = loader.ToolsFromMeta(m.meta) + + _, cmd := m.Update(keyRunes("U")) + if cmd == nil { + t.Fatal("[U] returned no command, want the self detection") + } + msg, ok := cmd().(updateDetectedMsg) + if !ok { + t.Fatalf("[U] command produced %T, want updateDetectedMsg", cmd()) + } + if !msg.self { + t.Error("updateDetectedMsg.self = false — an untracked keeptui's result would be dropped") + } + if msg.tool != selfToolName { + t.Errorf("updateDetectedMsg.tool = %q, want %q", msg.tool, selfToolName) + } + + // The command itself, driven directly with a name no PATH can resolve: no + // subprocess, and the self tag still rides along. + direct, ok := detectUpdateCmd(loader.Tool{Name: "keeptui-no-such-binary"}, true)().(updateDetectedMsg) + if !ok { + t.Fatalf("detectUpdateCmd produced %T, want updateDetectedMsg", direct) + } + if !direct.self || direct.tool != "keeptui-no-such-binary" { + t.Errorf("detectUpdateCmd msg = %+v, want it tagged self for the given tool", direct) + } + if !errors.Is(direct.err, updater.ErrUnknownManager) { + t.Errorf("detectUpdateCmd err = %v, want ErrUnknownManager for a name off PATH", direct.err) + } +} + +// TestSelfUpdateKeyInertWithoutBanner: at selfNone the key is unbound, and the +// busy refusal must never leak out ahead of that. Order matters because selfNone +// is the *only* state a dev build ever has: a `U` pressed during an ordinary tool +// update there used to answer "another update is running", which is a self-update +// key announcing itself on a build where the whole feature — request, banner, +// restart offer — is documented as absent. +// +// With a banner up the refusal is the point: one update at a time in both +// directions, reported rather than silent, because the banner on screen still +// advertises [U] and a tool update's only other indicator is a card spinner +// invisible unless that tool is selected. +func TestSelfUpdateKeyInertWithoutBanner(t *testing.T) { + tests := []struct { + name string + // appVersion overrides selfModel's release version; empty keeps it. + appVersion string + state selfState + updating string + wantBusy bool + }{ + {name: "no banner, idle", state: selfNone}, + {name: "no banner, tool update running", state: selfNone, updating: "rg"}, + {name: "dev build, tool update running", appVersion: "dev", state: selfNone, updating: "rg"}, + { + name: "pseudo-version build, tool update running", + appVersion: "v0.0.0-20260725115912-1be4bafa79c8", + state: selfNone, + updating: "rg", + }, + {name: "offer blocked", state: selfOffered, updating: "rg", wantBusy: true}, + // The guard is one field in both directions: a self-update already + // running blocks a second [U] exactly as a tool update does. + {name: "offer blocked by a running self-update", state: selfOffered, updating: selfToolName, wantBusy: true}, + {name: "folded offer blocked", state: selfDismissed, updating: "rg", wantBusy: true}, + {name: "restart offer blocked", state: selfUpdated, updating: "rg", wantBusy: true}, + {name: "folded restart offer blocked", state: selfUpdatedLater, updating: "rg", wantBusy: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shrinkStatusTTL(t) + m := selfModel(t, selfBarTools(), 120, tt.state) + if tt.appVersion != "" { + m = m.WithAppVersion(tt.appVersion) + } + m.updatingFor = tt.updating + + updated, cmd := m.Update(keyRunes("U")) + got := updated.(Model) + if got.selfState != tt.state { + t.Errorf("selfState = %v, want %v (a blocked or unbound U moves nothing)", got.selfState, tt.state) + } + if got.RestartRequested() { + t.Error("RestartRequested() = true, want the key to have done nothing") + } + if !tt.wantBusy { + if got.statusMsg != "" { + t.Errorf("statusMsg = %q, want silence — there is no banner to act on", got.statusMsg) + } + if cmd != nil { + t.Errorf("cmd = %T, want none", cmd()) + } + return + } + if got.statusMsg != updateBusyStatus { + t.Errorf("statusMsg = %q, want %q", got.statusMsg, updateBusyStatus) + } + // Only the status expiry rides along — no detection was started. + assertOnlyExpiryTick(t, cmd) + }) + } +} + +// TestToolUpdateKeyBlockedBySelfUpdate: one update at a time in the other +// direction too — [u] on a tool with a pending release must not start a second +// one while keeptui updates itself. Same updatingFor guard, mirrored by +// TestSelfUpdateKeyInertWithoutBanner's blocked rows for [U] under one. +func TestToolUpdateKeyBlockedBySelfUpdate(t *testing.T) { + shrinkStatusTTL(t) + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}}, selfOffered) + m.focus = focusBrief + m.versions["rg"] = VersionInfo{ + Installed: "0.1.0", + Latest: "1.0.0", + InstalledKnown: true, + InstalledPresent: true, + } + if !m.hasUpdate("rg") { + t.Fatal("fixture: rg must have a pending update for [u] to be live") + } + + updated, cmd := m.Update(keyRunes("u")) + got := updated.(Model) + if got.statusMsg != updateBusyStatus { + t.Errorf("statusMsg = %q, want %q", got.statusMsg, updateBusyStatus) + } + // No detection started — only the status expiry rides along. + assertOnlyExpiryTick(t, cmd) + if got.mode != modeNormal { + t.Errorf("mode = %v, want modeNormal (no confirm dialog)", got.mode) + } + if got.updatingFor != selfToolName || got.updateLogFor != selfToolName || !got.selfUpdating() { + t.Errorf("updatingFor = %q, updateLogFor = %q, selfUpdating() = %v, want the self-update untouched", + got.updatingFor, got.updateLogFor, got.selfUpdating()) + } +} + +// TestSelfKeysAreNormalModeOnly: U and X are bound in the normal-mode switch, so +// every input mode's handler owns them first — a capital letter typed into the +// tool-list search is query text, and an open overlay swallows it. +func TestSelfKeysAreNormalModeOnly(t *testing.T) { + m := selfModel(t, selfBarTools(), 120, selfOffered) + m = mustModel(m.Update(keyRunes("/"))) + m = mustModel(m.Update(keyRunes("X"))) + if m.selfState != selfOffered { + t.Errorf("selfState = %v, want the offer untouched by a searched X", m.selfState) + } + if got := m.search.Value(); got != "X" { + t.Errorf("search query = %q, want the literal X", got) + } + + m = selfModel(t, selfBarTools(), 120, selfUpdated) + m = mustModel(m.Update(keyRunes("?"))) + m = mustModel(m.Update(keyRunes("X"))) + if m.selfState != selfUpdated || m.mode != modeHotkeys { + t.Errorf("selfState = %v, mode = %v, want X swallowed by the hotkeys overlay", m.selfState, m.mode) + } +} + +// ---- detection, the confirm dialog and its bar ---- + +// TestSelfToolInheritsTrackedEntry: the detection target is the tracked entry +// when there is one, so an update_cmd override governs [U] exactly as it governs +// [u] on that row; otherwise it is synthesized from the constants. +func TestSelfToolInheritsTrackedEntry(t *testing.T) { + tracked := New([]loader.ToolMeta{ + {Name: "rg"}, + {Name: selfToolName, GitHub: version.SelfRepo, UpdateCmd: "brew upgrade keeptui"}, + }).selfTool() + if tracked.Name != selfToolName || tracked.UpdateCmd != "brew upgrade keeptui" { + t.Errorf("selfTool() = %#v, want the tracked entry with its update_cmd", tracked) + } + + // The synthesized entry's GitHub field carries the host-prefixed form every + // tracked tool has, not the bare owner/repo of version.SelfRepo. + synthetic := New([]loader.ToolMeta{{Name: "rg"}}).selfTool() + if synthetic.Name != selfToolName || synthetic.GitHub != "github.com/"+version.SelfRepo || synthetic.UpdateCmd != "" { + t.Errorf("selfTool() = %#v, want a synthesized keeptui entry", synthetic) + } + if loader.NormalizeRepo(synthetic.GitHub) != version.SelfRepo { + t.Errorf("NormalizeRepo(%q) = %q, want %q", + synthetic.GitHub, loader.NormalizeRepo(synthetic.GitHub), version.SelfRepo) + } +} + +// TestSelfDetectedAcceptance: a self detection result has no selection to match +// (keeptui is typically untracked, the tracker may be empty), so it is gated on +// the input mode and the update guard instead. A dropped result must leave the +// offer intact — [U] is the retry. +func TestSelfDetectedAcceptance(t *testing.T) { + plan := updater.Plan{Manager: "brew", Argv: []string{"brew", "upgrade", "keeptui"}, Display: "brew upgrade keeptui"} + tests := []struct { + name string + meta []loader.ToolMeta + mode inputMode + updatingFor string + wantConfirm bool + }{ + { + name: "another tool selected", + meta: []loader.ToolMeta{{Name: "rg"}}, + wantConfirm: true, + }, + { + name: "empty tracker", + wantConfirm: true, + }, + { + name: "note editor owns the input", + meta: []loader.ToolMeta{{Name: "rg"}}, + mode: modeEditNote, + }, + { + name: "hotkeys overlay owns the input", + meta: []loader.ToolMeta{{Name: "rg"}}, + mode: modeHotkeys, + }, + { + name: "a tool update is already running", + meta: []loader.ToolMeta{{Name: "rg"}}, + updatingFor: "rg", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selfModel(t, tt.meta, 100, selfOffered) + m.mode = tt.mode + m.updatingFor = tt.updatingFor + + m = mustModel(m.Update(updateDetectedMsg{tool: selfToolName, plan: plan, self: true})) + if tt.wantConfirm { + if m.mode != modeConfirmUpdate { + t.Fatalf("mode = %v, want modeConfirmUpdate", m.mode) + } + if m.updateTarget != selfToolName { + t.Errorf("updateTarget = %q, want %q", m.updateTarget, selfToolName) + } + if m.updatePlan.Display != plan.Display { + t.Errorf("updatePlan.Display = %q, want %q", m.updatePlan.Display, plan.Display) + } + return + } + if m.mode != tt.mode { + t.Errorf("mode = %v, want the dropped result to leave %v", m.mode, tt.mode) + } + if m.updateTarget != "" || m.updatePlan.Display != "" { + t.Errorf("updateTarget = %q, plan = %q, want no plan stored", m.updateTarget, m.updatePlan.Display) + } + if m.selfState != selfOffered { + t.Errorf("selfState = %v, want the offer left as a retry", m.selfState) + } + }) + } +} + +// TestSelfDetectedUnknownManager: a hand-installed binary has no manager to +// drive, which is a hint and not a dead-end dialog — the offer stays up. The +// wording is chosen by what the target *is* (isSelfUpdate), not by which key +// asked: keeptui's manual route is whatever installed it, a tool's is update_cmd, +// and the identical failure of the identical binary must not read two ways +// depending on whether [U] or [u] started it. +func TestSelfDetectedUnknownManager(t *testing.T) { + tests := []struct { + name string + appVersion string + self bool + want string + }{ + {name: "U", appVersion: "v0.4.2", self: true, want: "manually"}, + // [u] on a tracked keeptui row is the same self-update, so it gets the + // same wording even though msg.self is false. + {name: "u on a tracked keeptui row", appVersion: "v0.4.2", want: "manually"}, + // ...but only where the feature is live at all: on a dev build that press + // is a plain tool update and gets the tool wording. + {name: "u on a dev build", appVersion: "v0.0.0-20260725115912-1be4bafa79c8", want: "update_cmd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selfModel(t, []loader.ToolMeta{{Name: selfToolName}}, 100, selfOffered) + m.appVersion = tt.appVersion + + m = mustModel(m.Update(updateDetectedMsg{tool: selfToolName, err: updater.ErrUnknownManager, self: tt.self})) + if m.mode != modeNormal { + t.Fatalf("mode = %v, want modeNormal (no dialog)", m.mode) + } + if !strings.Contains(m.statusMsg, "no known updater for "+selfToolName) { + t.Errorf("statusMsg = %q, want it to name %s", m.statusMsg, selfToolName) + } + if !strings.Contains(m.statusMsg, tt.want) { + t.Errorf("statusMsg = %q, want the %q route", m.statusMsg, tt.want) + } + if m.selfState != selfOffered { + t.Errorf("selfState = %v, want the offer left as a retry", m.selfState) + } + }) + } +} + +// TestSelfConfirmEnterStartsWithEmptyTracker: the target comes from the plan, not +// from the selection, so enter starts the update even with nothing to select — +// reading it off selectedMeta would silently cancel exactly where the feature is +// needed most. +func TestSelfConfirmEnterStartsWithEmptyTracker(t *testing.T) { + m := selfModel(t, nil, 100, selfOffered) + m.mode = modeConfirmUpdate + m.updatePlan = updater.Plan{Manager: "brew", Argv: []string{"true"}, Display: "brew upgrade keeptui"} + m.updateTarget = selfToolName + m.updateLog = []string{"stale output from a previous run"} + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.mode != modeNormal { + t.Errorf("mode = %v, want modeNormal after enter", m.mode) + } + if m.updatingFor != selfToolName || m.updateLogFor != selfToolName { + t.Errorf("updatingFor = %q, updateLogFor = %q, want both %q", m.updatingFor, m.updateLogFor, selfToolName) + } + if !m.selfUpdateLog || !m.selfUpdating() { + t.Errorf("selfUpdateLog = %v, selfUpdating() = %v, want the self log marked live", m.selfUpdateLog, m.selfUpdating()) + } + if len(m.updateLog) != 0 { + t.Errorf("updateLog = %v, want reset to empty", m.updateLog) + } + if cmd == nil { + t.Error("cmd = nil, want the start+spinner batch") + } +} + +// TestSelfConfirmCancelClearsTarget: the target names a plan awaiting +// confirmation, so a cancelled dialog must not leave it set for the next one. +func TestSelfConfirmCancelClearsTarget(t *testing.T) { + m := selfModel(t, []loader.ToolMeta{{Name: "rg"}}, 100, selfOffered) + m.mode = modeConfirmUpdate + m.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "brew upgrade keeptui"} + m.updateTarget = selfToolName + + m = mustModel(m.Update(tea.KeyMsg{Type: tea.KeyEsc})) + if m.mode != modeNormal { + t.Errorf("mode = %v, want modeNormal", m.mode) + } + if m.updateTarget != "" { + t.Errorf("updateTarget = %q after cancel, want it cleared", m.updateTarget) + } + if m.updatingFor != "" || m.selfUpdateLog { + t.Errorf("updatingFor = %q, selfUpdateLog = %v, want nothing started", m.updatingFor, m.selfUpdateLog) + } +} + +// TestToolConfirmEnterReleasesSelfLog: a tool update takes the log buffer over, +// so a completed self-update's selection-independent claim on [3] goes with it — +// otherwise the tool's log would render under every other row too. +func TestToolConfirmEnterReleasesSelfLog(t *testing.T) { + m := selfModel(t, []loader.ToolMeta{{Name: "rg"}}, 100, selfOffered) + m.selfState = selfUpdated + m.selfUpdateLog = true + m.updateLogFor = selfToolName + m.mode = modeConfirmUpdate + m.updateTarget = "rg" + m.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "brew upgrade ripgrep"} + + m = mustModel(m.Update(tea.KeyMsg{Type: tea.KeyEnter})) + if m.updatingFor != "rg" || m.updateLogFor != "rg" { + t.Errorf("updatingFor = %q, updateLogFor = %q, want both rg", m.updatingFor, m.updateLogFor) + } + if m.selfUpdateLog { + t.Error("selfUpdateLog = true after a tool update started, want it released") + } +} + +// TestSelfConfirmBarNamesKeeptui: the confirm bar names the plan's own target, +// which for keeptui's update is neither the selected tool nor — with an empty +// tracker — anything at all. +func TestSelfConfirmBarNamesKeeptui(t *testing.T) { + for _, meta := range [][]loader.ToolMeta{{{Name: "rg"}}, nil} { + m := selfModel(t, meta, 100, selfOffered) + m.mode = modeConfirmUpdate + m.updatePlan = updater.Plan{Display: "brew upgrade keeptui"} + m.updateTarget = selfToolName + + bar := stripANSI(m.renderStatusBar()) + if !strings.Contains(bar, "update keeptui: brew upgrade keeptui") { + t.Errorf("tracker %v: confirm bar = %q, want it to name keeptui", meta, bar) + } + if strings.Contains(bar, "update rg") { + t.Errorf("tracker %v: confirm bar = %q, want no selected-tool name", meta, bar) + } + } + + // Regression: a tool plan still names its tool. + m := selfModel(t, []loader.ToolMeta{{Name: "rg"}}, 100, selfOffered) + m.mode = modeConfirmUpdate + m.updatePlan = updater.Plan{Display: "brew upgrade ripgrep"} + m.updateTarget = "rg" + if bar := stripANSI(m.renderStatusBar()); !strings.Contains(bar, "update rg: brew upgrade ripgrep") { + t.Errorf("tool confirm bar = %q, want it to name the plan's tool", bar) + } +} + +// ---- completion: success, failure and the restart offer ---- + +// TestSelfUpdateDoneSuccessUntracked: the self branch sits ahead of the +// toolByName early return, so an untracked keeptui's update actually completes — +// with the tool path it would finish silently and [U] restart would never appear. +func TestSelfUpdateDoneSuccessUntracked(t *testing.T) { + shrinkStatusTTL(t) + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) + m.updateLog = []string{"==> Upgrading keeptui", "installed"} + + updated, cmd := m.Update(updateDoneMsg{tool: selfToolName}) + m = updated.(Model) + if m.selfState != selfUpdated { + t.Errorf("selfState = %v, want selfUpdated (the restart offer)", m.selfState) + } + if m.statusMsg != "updated keeptui" { + t.Errorf("statusMsg = %q, want %q", m.statusMsg, "updated keeptui") + } + if m.updatingFor != "" || m.selfUpdating() { + t.Errorf("updatingFor = %q, selfUpdating() = %v, want the guard cleared", m.updatingFor, m.selfUpdating()) + } + // Nothing to re-detect: an untracked keeptui has no card and no ↑ marker, and + // this process is still the old binary either way. + assertOnlyExpiryTick(t, cmd) +} + +// TestSelfUpdateDoneSuccessTracked: a tracked keeptui does have a card and a ↑ +// marker, so the installed re-detect rides along. The batch is deliberately not +// executed — fetchInstalledCmd for this tool name would run the developer's own +// keeptui binary, making the test depend on the machine it runs on. +func TestSelfUpdateDoneSuccessTracked(t *testing.T) { + shrinkStatusTTL(t) + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo}}, selfOffered) + m.updateLog = []string{"installed"} + + updated, cmd := m.Update(updateDoneMsg{tool: selfToolName}) + m = updated.(Model) + if m.selfState != selfUpdated { + t.Errorf("selfState = %v, want selfUpdated", m.selfState) + } + if m.statusMsg != "updated keeptui" { + t.Errorf("statusMsg = %q, want %q", m.statusMsg, "updated keeptui") + } + if m.selfUpdating() { + t.Error("selfUpdating() = true after completion, want the guard cleared") + } + if cmd == nil { + t.Fatal("cmd = nil, want the expiry tick batched with the re-detect") + } + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("cmd produced %T (%d cmds), want a 2-command batch (tick + re-detect)", cmd(), len(batch)) + } +} + +// TestSelfUpdateDoneFailure: a failed self-update leaves the banner it started +// from — here the offer, so [U] is a retry — and seeds the log with the reason +// when the command produced none: the status bar points at [3] for it, so the +// panel itself must have been repainted (asserted on the viewport, not on a fresh +// renderHelpContent call, which would pass with no repaint at all). The state +// check here is a spot check on this one fixture and is deliberately shallow: the +// handler writes no selfState at all, which only a table over every prior state +// can show — TestSelfUpdateDoneFailureKeepsPriorState is that table. +func TestSelfUpdateDoneFailure(t *testing.T) { + logDir := t.TempDir() + restore := logx.SetDirForTesting(logDir) + defer restore() + + m := startedSelfUpdate(t, nil, selfOffered) + + m = mustModel(m.Update(updateDoneMsg{tool: selfToolName, err: errors.New("exit status 1")})) + // What the failure does to selfState is TestSelfUpdateDoneFailureKeepsPriorState's + // subject, over all five prior states; asserting it against this fixture's own + // seed would only restate the seed. + if !strings.Contains(m.statusMsg, "update failed") { + t.Errorf("statusMsg = %q, want an update-failed message", m.statusMsg) + } + if m.updatingFor != "" { + t.Errorf("updatingFor = %q, want the guard cleared", m.updatingFor) + } + if len(m.updateLog) != 1 || !strings.Contains(m.updateLog[0], "exit status 1") { + t.Errorf("updateLog = %#v, want the failure seeded for [3]", m.updateLog) + } + if view := stripANSI(m.helpViewport.View()); !strings.Contains(view, "exit status 1") { + t.Errorf("help viewport = %q, want the seeded failure painted into [3]", view) + } + // The same post-hoc record the tool path writes, and just as token-free. + log := logx.ReadAllForTesting(logDir) + if !strings.Contains(log, selfToolName) || !strings.Contains(log, "exit status 1") { + t.Errorf("log = %q, want the failed self-update recorded", log) + } + if strings.Contains(log, "token") { + t.Errorf("log leaked a token-ish word: %q", log) + } +} + +// TestUpdateFailureSeedsOnlyItsOwnLog: the seed guard is the buffer's owner, not +// just its emptiness — a failure for a tool that no longer owns the log (another +// update started meanwhile) must not append its reason to that other log. +func TestUpdateFailureSeedsOnlyItsOwnLog(t *testing.T) { + shrinkStatusTTL(t) + logDir := t.TempDir() + restore := logx.SetDirForTesting(logDir) + defer restore() + + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) + // The buffer has moved on to a tool update; the self log is empty history. + m.updateLogFor = "rg" + m.updateLog = nil + + m = mustModel(m.Update(updateDoneMsg{tool: selfToolName, err: errors.New("exit status 1")})) + if len(m.updateLog) != 0 { + t.Errorf("updateLog = %#v, want rg's buffer untouched by keeptui's failure", m.updateLog) + } + // The record still happens — only the on-screen seed is owner-gated. + if log := logx.ReadAllForTesting(logDir); !strings.Contains(log, "exit status 1") { + t.Errorf("log = %q, want the failure recorded anyway", log) + } +} + +// TestSelfUpdateDoneFailureKeepsPriorState: a failed self-update must not move +// selfState at all — whatever the banner said before the update it says again the +// moment updatingFor clears, so any write here can only walk a state back. All +// four non-trivial prior states are ways to get that wrong: an [X] folded +// mid-update is a deliberate "not now"; a pending [U] restart from an earlier +// successful update is still valid (that binary is on disk either way); and +// selfNone — reachable with no [U] press at all, since [u] on a tracked keeptui row +// is a self-update too and hasUpdate comes from the locally detected version, +// independent of a startup check that may have been rate-limited, offline, or +// simply said "not newer" — has no version behind it, so forcing "offered" there +// rendered a banner with a hole in it ("keeptui available") that no later +// selfCheckMsg could fill, the handler writing only from selfNone. +func TestSelfUpdateDoneFailureKeepsPriorState(t *testing.T) { + logDir := t.TempDir() + restore := logx.SetDirForTesting(logDir) + defer restore() + + tests := []struct { + name string + state selfState + latest string + wantContains string + wantAbsent string + }{ + {name: "never offered", state: selfNone, wantAbsent: "keeptui"}, + {name: "offered", state: selfOffered, latest: "v0.5.0", wantContains: "v0.5.0 available"}, + {name: "folded offer", state: selfDismissed, latest: "v0.5.0", wantContains: "keeptui ↑"}, + {name: "restart pending", state: selfUpdated, wantContains: "keeptui updated"}, + {name: "restart folded", state: selfUpdatedLater, wantContains: "keeptui [U] restart"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := startedSelfUpdate(t, nil, tt.state) + m.selfLatest = tt.latest + + m = mustModel(m.Update(updateDoneMsg{tool: selfToolName, err: errors.New("exit status 1")})) + if m.selfState != tt.state { + t.Errorf("selfState = %v, want the prior %v untouched", m.selfState, tt.state) + } + // The status bar as it reads once the transient "update failed" message + // has expired — that message outranks the banner while it is up. + m.statusMsg = "" + bar := stripANSI(m.renderStatusBar()) + if tt.wantContains != "" && !strings.Contains(bar, tt.wantContains) { + t.Errorf("status bar = %q, want %q", bar, tt.wantContains) + } + if tt.wantAbsent != "" && strings.Contains(bar, tt.wantAbsent) { + t.Errorf("status bar = %q, want no %q surface at all", bar, tt.wantAbsent) + } + }) + } +} + +// TestKeeptuiUpdateSelfHandlingGatedOnBuild: [u] on a tracked keeptui row reaches +// the completion handler on every build, so the version gate — not the target name +// — is what decides whether it is treated as a self-update. On a release build it +// is one (the restart offer appears even though the startup check never got to +// selfOffered); on a dev build, where the feature is documented as fully off, it +// stays a plain tool update: no panel-owning log, no banner, no Self group in [?], +// and [U] raises no restart request — which on such a build would re-exec the +// working copy and silently hand back the pre-update binary. +func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { + tests := []struct { + name string + appVersion string + wantSelf bool + }{ + {name: "release build", appVersion: "v0.4.2", wantSelf: true}, + {name: "dev build", appVersion: "dev"}, + {name: "pseudo-version build", appVersion: "v0.0.0-20260725115912-1be4bafa79c8"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shrinkStatusTTL(t) + t.Setenv("HOME", t.TempDir()) + meta := []loader.ToolMeta{{Name: selfToolName, GitHub: "github.com/" + version.SelfRepo}, {Name: "rg"}} + m := New(meta).WithAppVersion(tt.appVersion) + m = mustModel(m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})) + if m.selfState != selfNone { + t.Fatalf("selfState = %v, want selfNone before anything ran", m.selfState) + } + + // The tool route, exactly as [u] on the keeptui row leaves it. + m.mode = modeConfirmUpdate + m.updateTarget = selfToolName + m.updatePlan = updater.Plan{Manager: "brew", Argv: []string{"true"}, Display: "brew upgrade keeptui"} + m = mustModel(m.Update(tea.KeyMsg{Type: tea.KeyEnter})) + if m.selfUpdateLog != tt.wantSelf || m.selfUpdating() != tt.wantSelf { + t.Fatalf("selfUpdateLog = %v, selfUpdating() = %v, want both %v", + m.selfUpdateLog, m.selfUpdating(), tt.wantSelf) + } + + // The batch is deliberately not executed: fetchInstalledCmd for this name + // would run the developer's own keeptui binary. + m = mustModel(m.Update(updateDoneMsg{tool: selfToolName})) + if m.statusMsg != "updated keeptui" { + t.Errorf("statusMsg = %q, want %q on both paths", m.statusMsg, "updated keeptui") + } + wantState := selfNone + if tt.wantSelf { + wantState = selfUpdated + } + if m.selfState != wantState { + t.Fatalf("selfState = %v, want %v", m.selfState, wantState) + } + + m.statusMsg = "" + bar := stripANSI(m.renderStatusBar()) + overlay := stripANSI(m.renderHotkeys()) + m = mustModel(m.Update(keyRunes("U"))) + if tt.wantSelf { + if !strings.Contains(bar, "keeptui updated") { + t.Errorf("status bar = %q, want the restart offer", bar) + } + if !strings.Contains(overlay, "restart") { + t.Errorf("[?] overlay = %q, want the Self group's restart row", overlay) + } + if !m.RestartRequested() { + t.Error("RestartRequested() = false, want [U] to request the restart") + } + return + } + if strings.Contains(bar, "keeptui updated") || strings.Contains(bar, "[U]") { + t.Errorf("status bar = %q, want no self-update surface on a dev build", bar) + } + if strings.Contains(overlay, "Self") { + t.Errorf("[?] overlay = %q, want no Self group on a dev build", overlay) + } + if m.RestartRequested() { + t.Error("RestartRequested() = true on a dev build, want [U] unbound (it would re-exec the working copy)") + } + // A plain tool update also keeps the log per-tool sticky, so moving off + // the keeptui row hands [3] back. + m.focus = focusTools + if moved := mustModel(m.Update(keyRunes("j"))); moved.showsUpdateLog() { + t.Error("showsUpdateLog() = true under another row, want the plain per-tool log") + } + }) + } +} + +// ---- who owns panel [3] while an update streams ---- + +// TestShowsUpdateLog: the single predicate behind every [3] site. The tool path +// is per-tool sticky (log only under its own row), the self path is +// selection-independent — including an empty tracker, where nothing is selected. +func TestShowsUpdateLog(t *testing.T) { + tests := []struct { + name string + meta []loader.ToolMeta + selected int + updateLogFor string + selfLog bool + want bool + }{ + {name: "idle", meta: []loader.ToolMeta{{Name: "rg"}}}, + {name: "tool log under its own row", meta: []loader.ToolMeta{{Name: "rg"}}, updateLogFor: "rg", want: true}, + { + name: "tool log under another row", + meta: []loader.ToolMeta{{Name: "rg"}, {Name: "fd"}}, + selected: 1, + updateLogFor: "rg", + }, + { + name: "self log under a foreign row", + meta: []loader.ToolMeta{{Name: "rg"}}, + updateLogFor: selfToolName, + selfLog: true, + want: true, + }, + {name: "self log with an empty tracker", updateLogFor: selfToolName, selfLog: true, want: true}, + {name: "empty tracker, no log"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(tt.meta) + m.metaSelected = tt.selected + m.updateLogFor = tt.updateLogFor + m.selfUpdateLog = tt.selfLog + if got := m.showsUpdateLog(); got != tt.want { + t.Errorf("showsUpdateLog() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestSelfUpdateLogOwnsHelpPanel: the log and the [3] Update title are reachable +// with keeptui untracked and even with an empty tracker — where selectedMeta, +// which used to gate both, has nothing to return. +func TestSelfUpdateLogOwnsHelpPanel(t *testing.T) { + for _, meta := range [][]loader.ToolMeta{{{Name: "rg"}}, nil} { + m := startedSelfUpdate(t, meta, selfOffered) + m.updateLog = []string{"==> Upgrading keeptui"} + + if content := stripANSI(m.renderHelpContent()); !strings.Contains(content, "==> Upgrading keeptui") { + t.Errorf("tracker %v: [3] = %q, want the self-update log", meta, content) + } + if panel := stripANSI(m.renderHelp()); !strings.Contains(panel, "[3] Update") { + t.Errorf("tracker %v: panel title missing [3] Update", meta) + } + } +} + +// TestSelfUpdateChunkRepaintsUnderForeignSelection: the streaming output has to +// reach the visible panel even though the selected row belongs to another tool — +// the repaint gate used to require the selection to be the updating tool. +func TestSelfUpdateChunkRepaintsUnderForeignSelection(t *testing.T) { + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) + + ch := make(chan updateLine, 1) + m = feedChunk(m, updateChunkMsg{tool: selfToolName, line: "==> Downloading", ch: ch}) + if len(m.updateLog) != 1 || m.updateLog[0] != "==> Downloading" { + t.Fatalf("updateLog = %#v, want the chunk buffered", m.updateLog) + } + if view := stripANSI(m.helpViewport.View()); !strings.Contains(view, "==> Downloading") { + t.Errorf("help viewport = %q, want the live self-update log", view) + } +} + +// TestSelfUpdateLogReleasedWhenDone: the self log is selection-independent, so +// something has to hand [3] back — a selection move or an explicit [h]/[m]/[r], +// but only once the update has finished. +func TestSelfUpdateLogReleasedWhenDone(t *testing.T) { + // A selection move after completion returns the panel to the tool's help. + m := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}, {Name: "fd"}}, selfOffered) + m.updateLog = []string{"installed"} + m.updatingFor = "" // finished + m.focus = focusTools + + moved := mustModel(m.Update(keyRunes("j"))) + if moved.selfUpdateLog { + t.Error("selfUpdateLog = true after a selection move, want the panel handed back") + } + if strings.Contains(stripANSI(moved.renderHelpContent()), "installed") { + t.Error("[3] still shows the finished self-update log after a selection move") + } + + // The same move while it is still streaming keeps the log — that output is + // only visible there. + m.updatingFor = selfToolName + live := mustModel(m.Update(keyRunes("j"))) + if !live.selfUpdateLog { + t.Error("selfUpdateLog = false during a live update, want the log kept") + } + + // [h] releases it too, including with an empty tracker, where switchHelpMode + // returns early on the missing selection. + empty := startedSelfUpdate(t, nil, selfOffered) + empty.updateLog = []string{"installed"} + empty.updatingFor = "" + empty.focus = focusBrief + empty = mustModel(empty.Update(keyRunes("h"))) + if empty.selfUpdateLog { + t.Error("selfUpdateLog = true after [h] with an empty tracker, want it released") + } +} + +// TestSelfUpdateLogSuppressesHelpNav covers the setHelpContent site of +// showsUpdateLog: keeptui's own log owns [3] regardless of the selection, so the +// entry index must stay empty even though the selected tool has cached --help — +// otherwise j/k would drive a spotlight computed for that help over log lines. +func TestSelfUpdateLogSuppressesHelpNav(t *testing.T) { + base := func() Model { + // WithAppVersion because the fixture below claims a *live* self-update, and + // selfCheckEnabled gates that state: without it selfUpdating() is false + // whatever updatingFor says, and the fixture would be a state the app can + // never reach. + m := newTestModel(focusHelp).WithAppVersion("v0.4.2") + m.helpW = 62 + m.helpCache["git"] = [2]string{helpModeHelp: navHelpFixture} + return m + } + + // Control: the same fixture is navigable with no log in the way. + control := base() + control.setHelpContent() + if len(control.helpEntries) != 2 { + t.Fatalf("control helpEntries = %v, want the 2 fixture entries", control.helpEntries) + } + + m := base() + m.updatingFor = selfToolName + m.updateLogFor = selfToolName + m.selfUpdateLog = true + m.updateLog = []string{"==> Upgrading keeptui"} + m.setHelpContent() + + if len(m.helpEntries) != 0 { + t.Errorf("helpEntries = %v, want empty while the self-update log owns [3]", m.helpEntries) + } + if m.helpBase != "" { + t.Errorf("helpBase = %q, want empty (the log is not colorized help)", m.helpBase) + } + if content := stripANSI(m.renderHelpContent()); !strings.Contains(content, "==> Upgrading keeptui") { + t.Errorf("[3] = %q, want the self-update log", content) + } + // With no entries j is plain scroll, so the spotlight cursor stays off. + if moved := mustModel(m.Update(keyRunes("j"))); moved.helpNavIdx != -1 { + t.Errorf("helpNavIdx = %d after j, want -1 (no spotlight over log lines)", moved.helpNavIdx) + } +} + +// TestSelfUpdateLogSkipsHelpFetch covers the autoFetchCmdsForSelected site of +// showsUpdateLog: a selection move under a live self-update must not start a +// --help probe or set helpLoadingFor, or a late helpOutputMsg (or the +// "Loading..." state) would clobber the log. +func TestSelfUpdateLogSkipsHelpFetch(t *testing.T) { + live := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) + live.helpMode = helpModeHelp + live.updateLog = []string{"==> Upgrading keeptui"} + + live.autoFetchCmdsForSelected() + if live.helpLoadingFor != "" { + t.Errorf("helpLoadingFor = %q, want no --help probe while the self log owns [3]", live.helpLoadingFor) + } + if content := stripANSI(live.renderHelpContent()); !strings.Contains(content, "==> Upgrading keeptui") { + t.Errorf("[3] = %q, want the self-update log", content) + } + + // Control: once the log is released the same tool does get its probe. + control := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) + control.helpMode = helpModeHelp + control.updatingFor, control.updateLogFor, control.selfUpdateLog = "", "", false + control.autoFetchCmdsForSelected() + if control.helpLoadingFor != "rg" { + t.Errorf("helpLoadingFor = %q, want rg with no log in the way", control.helpLoadingFor) + } +} diff --git a/internal/model/update_test.go b/internal/model/update_test.go index fb34404..7f28029 100644 --- a/internal/model/update_test.go +++ b/internal/model/update_test.go @@ -230,7 +230,7 @@ func TestWaitForChunkCmd(t *testing.T) { // plan without any detection subprocess, and detectUpdateCmd surfaces it as an // updateDetectedMsg. func TestDetectUpdateCmdCustom(t *testing.T) { - msg := detectUpdateCmd(loader.Tool{Name: "git", UpdateCmd: "brew upgrade git"})() + msg := detectUpdateCmd(loader.Tool{Name: "git", UpdateCmd: "brew upgrade git"}, false)() det, ok := msg.(updateDetectedMsg) if !ok { t.Fatalf("got %T, want updateDetectedMsg", msg) @@ -241,6 +241,9 @@ func TestDetectUpdateCmdCustom(t *testing.T) { if det.tool != "git" || det.plan.Manager != "custom" || det.plan.Display != "brew upgrade git" { t.Errorf("unexpected plan: %#v", det.plan) } + if det.self { + t.Error("self = true for a tool detection, want false") + } } // TestStartUpdateCmdStreamsToCompletion drives a real trivial subprocess end to diff --git a/internal/version/github.go b/internal/version/github.go index 374a3df..e47d7a0 100644 --- a/internal/version/github.go +++ b/internal/version/github.go @@ -319,6 +319,28 @@ type CacheEntry struct { // deliberately stale (rate-limited) repo pass as fresh and serve a blank // card for the whole TTL. Two timestamps keep the poison guards independent. ReadmeCheckedAt time.Time `json:"readme_checked_at,omitzero"` + // ReleaseCheckedAt mirrors ReadmeCheckedAt for the release-only pass that + // SelfLatest makes (selfcheck.go), and is separate from CheckedAt for the + // same reason: getRepoData's freshness gate is CheckedAt-only with no content + // check, so a release-only pass stamping the shared timestamp would mark a + // never-fetched (or deliberately stale) repo card as fresh and serve a blank + // card for the whole TTL. Each poison guard owns its own timestamp. + ReleaseCheckedAt time.Time `json:"release_checked_at,omitzero"` + // ReleaseMissing records that the last conclusive release fetch answered 404 + // ("no latest release"): a repo that never published one, a single release + // converted to a draft, or a repo the token cannot see. It exists so that + // negative can be remembered *without* clearing the release tuple + // (Latest/Body/HtmlUrl/PublishedAt), which belongs to the shared repo card and + // which every pass here preserves on a 404 — getRepoData writes the tuple only + // when the fetch succeeded, getChangelog falls back to the cached body, and + // SelfLatest would otherwise be the one writer that destroys another feature's + // content. Maintained by every release fetch — all three of SelfLatest, + // getRepoData and getChangelog set it on a 404 and clear it on a fetched + // release, including getChangelog's error path, which is the *only* pass that + // still reaches /releases/latest when CheckedAt is fresh and Body is empty. + // Read only by the self-check, whose banner must not offer an update to a + // release that is gone. + ReleaseMissing bool `json:"release_missing,omitempty"` } // RepoCard holds full repository metadata for display in the TUI. @@ -461,12 +483,11 @@ func getRepoData(githubField string, force bool) RepoData { if conclusive { e.CheckedAt = time.Now() } - if relErr == nil { - e.Latest = info.Tag - e.Body = info.Body - e.HtmlUrl = info.HtmlUrl - e.PublishedAt = info.PublishedAt - } + // The release half of the write — tuple on success, ReleaseMissing both + // ways — is applyReleaseOutcome's single definition, shared with the two + // other passes that fetch a release, so no writer can maintain one half of + // the flag's contract and forget the other. + e = applyReleaseOutcome(e, info, relErr) if infoErr == nil { e.RepoStatus = repoStatus e.About = about @@ -515,6 +536,17 @@ func getChangelog(githubField string, force bool) (ReleaseInfo, error) { info, err := fetchRelease(repo) if err != nil { + // A 404 is conclusive, so it is recorded as a flag here too — the tuple + // below is still served from cache, this pass destroys nothing (see + // CacheEntry.ReleaseMissing). It matters because this is the only pass + // that reaches /releases/latest when CheckedAt is fresh but Body is empty + // (a release published without notes): getRepoData short-circuits, so + // without this write nobody would observe a release that has since been + // deleted or drafted, and the self-check's cached-tag branch would keep + // offering it for the rest of the TTL. + if errors.Is(err, errNoReleases) { + markReleaseMissing(repo) + } if cached { return ReleaseInfo{Tag: entry.Latest, Body: entry.Body, HtmlUrl: entry.HtmlUrl, PublishedAt: entry.PublishedAt}, nil } @@ -523,13 +555,11 @@ func getChangelog(githubField string, force bool) (ReleaseInfo, error) { // Mutate a copy of the existing entry instead of building a literal: a // literal only carries the fields it knows about and silently wipes every - // other one (Readme/ReadmeCheckedAt) on each changelog fetch. + // other one (Readme/ReadmeCheckedAt) on each changelog fetch. The release half + // of the write is applyReleaseOutcome's, shared with the two other passes that + // fetch a release; this one only adds its own freshness stamp. updateCacheEntry(repo, func(existing CacheEntry) CacheEntry { - e := existing - e.Latest = info.Tag - e.Body = info.Body - e.HtmlUrl = info.HtmlUrl - e.PublishedAt = info.PublishedAt + e := applyReleaseOutcome(existing, info, nil) e.CheckedAt = time.Now() return e }) @@ -780,6 +810,41 @@ func updateCacheEntry(repo string, mutate func(existing CacheEntry) CacheEntry) SaveCache(cache) } +// applyReleaseOutcome folds the result of one /releases/latest fetch into a cache +// entry. It is the single definition of what a release fetch does to the entry, +// used by all three passes that make one (getRepoData, getChangelog, SelfLatest) +// so the two halves of the ReleaseMissing contract — set on a 404, cleared on a +// fetched release — cannot be maintained by one writer and forgotten by the next. +// +// The release tuple is written only on success: it belongs to the shared repo +// card, and a 404 records the negative in the flag instead of destroying content +// another feature displays (see CacheEntry.ReleaseMissing). A transient failure +// (rate limit, network, 5xx) touches nothing at all — the entry stays as it was so +// the next pass retries. Freshness timestamps are the caller's business: each pass +// stamps its own (CheckedAt / ReleaseCheckedAt) under its own conclusiveness rule. +func applyReleaseOutcome(e CacheEntry, info ReleaseInfo, err error) CacheEntry { + switch { + case err == nil: + e.Latest = info.Tag + e.Body = info.Body + e.HtmlUrl = info.HtmlUrl + e.PublishedAt = info.PublishedAt + e.ReleaseMissing = false + case errors.Is(err, errNoReleases): + e.ReleaseMissing = true + } + return e +} + +// markReleaseMissing records a 404 on its own, for the one caller that observes +// the negative outside an entry write it was making anyway (getChangelog's error +// path, which serves the cached tuple and has nothing else to store). +func markReleaseMissing(repo string) { + updateCacheEntry(repo, func(existing CacheEntry) CacheEntry { + return applyReleaseOutcome(existing, ReleaseInfo{}, errNoReleases) + }) +} + func SaveCache(c Cache) { path, err := cacheFilePath() if err != nil { diff --git a/internal/version/main_test.go b/internal/version/main_test.go index cc94368..f10bc13 100644 --- a/internal/version/main_test.go +++ b/internal/version/main_test.go @@ -1,7 +1,12 @@ package version import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" + "strings" + "sync" "testing" "github.com/stanlyzoolo/keeptui/internal/logx" @@ -45,6 +50,90 @@ func TestMain(m *testing.M) { os.Exit(code) } +// apiHandlers lets a test drive the endpoints it is about and leave the rest to +// apiServer's canned answers. A nil field keeps the canned handler, so a test +// names only the endpoint it is testing. +type apiHandlers struct { + release http.HandlerFunc // /releases/latest + readme http.HandlerFunc // /readme + repoInfo http.HandlerFunc // /repos/{owner}/{repo} +} + +// apiHits counts requests per endpoint class, so a test can assert a call was +// served from cache.json rather than from the network. Tests that only need the +// server discard it. +type apiHits struct { + mu sync.Mutex + release int + repoInfo int +} + +func (h *apiHits) hit(counter *int) { + h.mu.Lock() + defer h.mu.Unlock() + *counter++ +} + +func (h *apiHits) counts() (release, repoInfo int) { + h.mu.Lock() + defer h.mu.Unlock() + return h.release, h.repoInfo +} + +// apiServer wires testAPIBase/testCacheDir to an httptest server that answers +// every GitHub endpoint this package fetches — caller-driven where the test says +// so, with plausible canned data everywhere else — for the duration of the test. +// It is the single fixture for both the README and the self-check suites: they +// differ only in which endpoint they drive, and duplicating the wiring is how the +// two copies drifted apart on the token reset. +func apiServer(t *testing.T, h apiHandlers) *apiHits { + t.Helper() + hits := &apiHits{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + hits.hit(&hits.release) + if h.release != nil { + h.release(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"tag_name": "v1.0.0", "body": "notes"}) + case strings.HasSuffix(r.URL.Path, "/readme"): + if h.readme != nil { + h.readme(w, r) + return + } + _, _ = w.Write([]byte("# docs")) + case strings.HasSuffix(r.URL.Path, "/languages"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]int{"Go": 100}) + default: + hits.hit(&hits.repoInfo) + if h.repoInfo != nil { + h.repoInfo(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "archived": false, "description": "tracker", "stargazers_count": 5, + }) + } + })) + origAPIBase, origCacheDir := testAPIBase, testCacheDir + testAPIBase = srv.URL + testCacheDir = t.TempDir() + // Redirect the token too: without this the fetchers read the developer's real + // ~/.config/keeptui/token and doGH sends it to this local server. + t.Setenv("GITHUB_TOKEN", "") + resetTokenState(t, t.TempDir()) + t.Cleanup(func() { + srv.Close() + testAPIBase, testCacheDir = origAPIBase, origCacheDir + }) + return hits +} + // TestConfigDirIsolated fails if the package-wide isolation above is ever // removed: without it a test that writes the cache or saves a token rewrites the // real user config. diff --git a/internal/version/readme_test.go b/internal/version/readme_test.go index 5098d2e..7842974 100644 --- a/internal/version/readme_test.go +++ b/internal/version/readme_test.go @@ -11,44 +11,6 @@ import ( "time" ) -// readmeServer spins up an httptest server that answers /readme from a -// caller-controlled handler and every other endpoint with plausible repo data, -// wiring testAPIBase/testCacheDir to it for the duration of the test. -func readmeServer(t *testing.T, readme http.HandlerFunc) *httptest.Server { - t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/readme"): - readme(w, r) - case strings.HasSuffix(r.URL.Path, "/languages"): - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]int{"Go": 100}) - case strings.HasSuffix(r.URL.Path, "/latest"): - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"tag_name": "v1.0.0", "body": "notes"}) - default: - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "archived": false, "description": "tool", "stargazers_count": 3, - }) - } - })) - origAPIBase := testAPIBase - origCacheDir := testCacheDir - testAPIBase = srv.URL - testCacheDir = t.TempDir() - // Redirect the token too: without this the tests read the developer's real - // ~/.config/keeptui/token and doGH sends it to this local server. - t.Setenv("GITHUB_TOKEN", "") - resetTokenState(t, t.TempDir()) - t.Cleanup(func() { - srv.Close() - testAPIBase = origAPIBase - testCacheDir = origCacheDir - }) - return srv -} - // TestGetReadmeSuccessAndCache verifies the raw markdown round-trip, that the // raw media type reaches the API, and that a second call within TTL is served // from cache.json without a second request. @@ -57,14 +19,14 @@ func TestGetReadmeSuccessAndCache(t *testing.T) { var mu sync.Mutex requests := 0 accepts := []string{} - readmeServer(t, func(w http.ResponseWriter, r *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, r *http.Request) { mu.Lock() requests++ accepts = append(accepts, r.Header.Get("Accept")) mu.Unlock() w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte(body)) - }) + }}) got, err := GetReadme("github.com/owner/repo") if err != nil { @@ -110,9 +72,9 @@ func TestGetReadmeSuccessAndCache(t *testing.T) { // TestGetReadmeNotFound verifies a 404 maps to the typed ErrNoReadme. func TestGetReadmeNotFound(t *testing.T) { - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) - }) + }}) _, err := GetReadme("github.com/owner/repo") if !errors.Is(err, ErrNoReadme) { @@ -123,10 +85,10 @@ func TestGetReadmeNotFound(t *testing.T) { // TestGetReadmeRateLimited verifies a 403 with an exhausted quota maps to // ErrRateLimited, the same classification the other fetchers produce. func TestGetReadmeRateLimited(t *testing.T) { - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-RateLimit-Remaining", "0") w.WriteHeader(http.StatusForbidden) - }) + }}) _, err := GetReadme("github.com/owner/repo") if !errors.Is(err, ErrRateLimited) { @@ -139,13 +101,13 @@ func TestRefreshReadmeBypassesTTL(t *testing.T) { var mu sync.Mutex body := "first" requests := 0 - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { mu.Lock() requests++ b := body mu.Unlock() _, _ = w.Write([]byte(b)) - }) + }}) if _, err := GetReadme("github.com/owner/repo"); err != nil { t.Fatalf("setup GetReadme: %v", err) @@ -175,7 +137,7 @@ func TestRefreshReadmeBypassesTTL(t *testing.T) { func TestGetReadmeFailureKeepsCached(t *testing.T) { var mu sync.Mutex fail := false - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { mu.Lock() f := fail mu.Unlock() @@ -184,7 +146,7 @@ func TestGetReadmeFailureKeepsCached(t *testing.T) { return } _, _ = w.Write([]byte("# cached docs")) - }) + }}) if _, err := GetReadme("github.com/owner/repo"); err != nil { t.Fatalf("setup GetReadme: %v", err) @@ -220,9 +182,9 @@ func TestGetReadmeFailureKeepsCached(t *testing.T) { // README fields over from the existing entry — a CacheEntry{...} literal there // silently wiped them on every changelog fetch. func TestGetChangelogPreservesReadme(t *testing.T) { - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("# keep me")) - }) + }}) if _, err := GetReadme("github.com/owner/repo"); err != nil { t.Fatalf("setup GetReadme: %v", err) @@ -248,42 +210,22 @@ func TestGetChangelogPreservesReadme(t *testing.T) { func TestGetReadmeDoesNotRefreshRepoCard(t *testing.T) { var mu sync.Mutex infoRequests := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/readme"): - _, _ = w.Write([]byte("# docs")) - case strings.HasSuffix(r.URL.Path, "/languages"): - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]int{"Go": 1}) - case strings.HasSuffix(r.URL.Path, "/latest"): - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"tag_name": "v2.0.0"}) - default: - mu.Lock() - infoRequests++ - n := infoRequests - mu.Unlock() - if n == 1 { - // First repo-info pass is rate-limited: the entry must stay stale. - w.Header().Set("X-RateLimit-Remaining", "0") - w.WriteHeader(http.StatusForbidden) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "archived": false, "description": "recovered", "stargazers_count": 7, - }) + apiServer(t, apiHandlers{repoInfo: func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + infoRequests++ + n := infoRequests + mu.Unlock() + if n == 1 { + // First repo-info pass is rate-limited: the entry must stay stale. + w.Header().Set("X-RateLimit-Remaining", "0") + w.WriteHeader(http.StatusForbidden) + return } - })) - origAPIBase := testAPIBase - origCacheDir := testCacheDir - testAPIBase = srv.URL - testCacheDir = t.TempDir() - defer func() { - srv.Close() - testAPIBase = origAPIBase - testCacheDir = origCacheDir - }() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "archived": false, "description": "recovered", "stargazers_count": 7, + }) + }}) // Partial failure: release succeeds, repo-info rate-limited → entry stale. if d := GetRepoData("github.com/owner/repo"); d.About != "" { @@ -311,9 +253,9 @@ func TestGetReadmeDoesNotRefreshRepoCard(t *testing.T) { // startup and every refresh, so a CacheEntry{...} literal there would wipe a // cached README on the very next version poll. func TestGetRepoDataPreservesReadme(t *testing.T) { - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("# keep me")) - }) + }}) if _, err := GetReadme("github.com/owner/repo"); err != nil { t.Fatalf("setup GetReadme: %v", err) @@ -338,9 +280,9 @@ func TestGetRepoDataPreservesReadme(t *testing.T) { // is cut off and marked rather than carried whole. func TestFetchReadmeTruncatesOversized(t *testing.T) { huge := strings.Repeat("x", readmeMaxBytes+4096) - readmeServer(t, func(w http.ResponseWriter, _ *http.Request) { + apiServer(t, apiHandlers{readme: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(huge)) - }) + }}) md, err := GetReadme("github.com/owner/repo") if err != nil { diff --git a/internal/version/selfcheck.go b/internal/version/selfcheck.go new file mode 100644 index 0000000..682a796 --- /dev/null +++ b/internal/version/selfcheck.go @@ -0,0 +1,97 @@ +package version + +import ( + "errors" + "time" +) + +// SelfRepo is keeptui's own GitHub repository. The self-check does not depend on +// meta.yaml — an untracked keeptui is the feature's main case — so the ref is a +// constant rather than a tool field. +const SelfRepo = "stanlyzoolo/keeptui" + +// SelfLatest returns keeptui's own latest release tag. It is a release-only pass: +// unlike GetRepoData it never touches /repos/{repo} or /languages, so a startup +// self-check costs one request per TTL window instead of three. +// +// The tag is served from cache.json when the entry is fresh, and freshness has +// its own timestamp (ReleaseCheckedAt) so this pass can never mark a repo card as +// fresh-but-blank — the same independence ReadmeCheckedAt buys the README. A +// tracked keeptui shares the cache entry with the full repo pass in both +// directions: a fresh full pass makes the self-check free, and a self-check never +// refreshes the card. +// +// An empty tag with a nil error means the answer is conclusively "no release +// published" (a 404 on /releases/latest). That is not a failure, and it is +// remembered for the TTL like any other answer (in CacheEntry.ReleaseMissing, not +// by wiping the tuple a tracked keeptui's card shows), so a repo without releases +// is not re-probed on every launch. A transient failure (rate limit, network, +// 5xx) returns the error and stamps nothing, so the next launch retries. +func SelfLatest() (string, error) { + // SelfRepo is a constant already in the normalized "owner/repo" form, so this + // cannot fail — the call names the shape of the cache key rather than parsing. + repo := extractRepo(SelfRepo) + + if tag, ok := selfCachedTag(LoadCache()[repo]); ok { + return tag, nil + } + + info, err := fetchRelease(repo) + if err != nil && !errors.Is(err, errNoReleases) { + return "", err + } + missing := err != nil // only errNoReleases gets past the check above + + // Both remaining outcomes are conclusive and stamp ReleaseCheckedAt — and only + // ReleaseCheckedAt, which is the whole point of the separate timestamp: a + // release-only side pass must never mark the shared repo card as fresh. What + // the fetch does to the entry itself (the tuple on success, ReleaseMissing + // either way) is applyReleaseOutcome's single definition, shared with the two + // passes that fetch a release alongside the card, so a 404 records the negative + // without destroying content a tracked keeptui's card shows. + updateCacheEntry(repo, func(existing CacheEntry) CacheEntry { + e := applyReleaseOutcome(existing, info, err) + e.ReleaseCheckedAt = time.Now() + return e + }) + // The flag, not the tuple, is the answer — exactly as on the cache-hit branch + // above: a preserved tag belongs to the card, never to the update offer. + if missing { + return "", nil + } + return info.Tag, nil +} + +// selfCachedTag answers the self-check from a cache entry, reporting whether the +// entry answers at all. Either timestamp counts, since both are stamped only by a +// conclusive pass, but they carry different guarantees: ReleaseCheckedAt is +// written by SelfLatest alone and is an answer on its own (the tag when there is +// one, the empty string when ReleaseMissing says there is not). CheckedAt is +// stamped by the two passes that fetch a release alongside the repo card +// (getRepoData and getChangelog), whose gates have no release-content check at +// all, so it counts only when one of them actually left an answer behind — a tag +// or the same recorded negative. +// +// All three release fetches maintain ReleaseMissing through applyReleaseOutcome, +// which is what lets the answer be read off the flag instead of the tuple: a tag +// preserved from a release that has since been deleted stays available to the +// card and is *not* offered as an update. +func selfCachedTag(e CacheEntry) (string, bool) { + if time.Since(e.ReleaseCheckedAt) < cacheTTL { + return selfTagOf(e), true + } + if time.Since(e.CheckedAt) < cacheTTL && (e.Latest != "" || e.ReleaseMissing) { + return selfTagOf(e), true + } + return "", false +} + +// selfTagOf is the entry's answer to "which release should the banner offer": +// nothing when the last conclusive fetch found no latest release, the cached tag +// otherwise. +func selfTagOf(e CacheEntry) string { + if e.ReleaseMissing { + return "" + } + return e.Latest +} diff --git a/internal/version/selfcheck_test.go b/internal/version/selfcheck_test.go new file mode 100644 index 0000000..b1fdd89 --- /dev/null +++ b/internal/version/selfcheck_test.go @@ -0,0 +1,429 @@ +package version + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + "time" +) + +// releaseJSON answers /releases/latest with a full release payload. +func releaseJSON(tag string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "tag_name": tag, + "body": "release notes", + "html_url": "https://github.com/" + SelfRepo + "/releases/tag/" + tag, + "published_at": "2026-07-01T00:00:00Z", + }) + } +} + +// TestSelfLatestColdFetch verifies the release-only pass: it returns the tag, +// stamps ReleaseCheckedAt, leaves CheckedAt zero (so the repo card is not marked +// fresh-but-blank), and never touches the repo-info endpoint. +func TestSelfLatestColdFetch(t *testing.T) { + hits := apiServer(t, apiHandlers{release: releaseJSON("v0.5.0")}) + + got, err := SelfLatest() + if err != nil { + t.Fatalf("SelfLatest: %v", err) + } + if got != "v0.5.0" { + t.Errorf("SelfLatest = %q, want v0.5.0", got) + } + + entry, ok := LoadCache()[SelfRepo] + if !ok { + t.Fatalf("cache has no entry for %s", SelfRepo) + } + if entry.Latest != "v0.5.0" { + t.Errorf("cached Latest = %q, want v0.5.0", entry.Latest) + } + if entry.ReleaseCheckedAt.IsZero() { + t.Error("ReleaseCheckedAt is zero, want stamped") + } + if !entry.CheckedAt.IsZero() { + t.Errorf("CheckedAt = %v, want untouched by a release-only pass", entry.CheckedAt) + } + if entry.HtmlUrl == "" || entry.PublishedAt == "" || entry.Body == "" { + t.Errorf("HtmlUrl/PublishedAt/Body = %q/%q/%q, want the whole release tuple merged", + entry.HtmlUrl, entry.PublishedAt, entry.Body) + } + if rel, info := hits.counts(); rel != 1 || info != 0 { + t.Errorf("requests = release %d / repo-info %d, want 1 / 0", rel, info) + } +} + +// TestSelfLatestServedFromReleaseStamp verifies a second call inside the TTL +// makes no request at all. +func TestSelfLatestServedFromReleaseStamp(t *testing.T) { + hits := apiServer(t, apiHandlers{release: releaseJSON("v0.5.0")}) + + if _, err := SelfLatest(); err != nil { + t.Fatalf("setup SelfLatest: %v", err) + } + got, err := SelfLatest() + if err != nil { + t.Fatalf("second SelfLatest: %v", err) + } + if got != "v0.5.0" { + t.Errorf("SelfLatest = %q, want v0.5.0 from cache", got) + } + if rel, _ := hits.counts(); rel != 1 { + t.Errorf("release requests = %d, want 1 (second read served from cache)", rel) + } +} + +// TestSelfLatestServedFromFullPass verifies the other freshness source: a +// tracked keeptui whose full repo pass already ran makes the self-check free. +func TestSelfLatestServedFromFullPass(t *testing.T) { + hits := apiServer(t, apiHandlers{release: releaseJSON("v0.6.0")}) + + if d := GetRepoData("https://github.com/" + SelfRepo); d.Latest != "v0.6.0" { + t.Fatalf("setup GetRepoData: Latest = %q, want v0.6.0", d.Latest) + } + if e := LoadCache()[SelfRepo]; !e.ReleaseCheckedAt.IsZero() { + t.Fatalf("full pass stamped ReleaseCheckedAt (%v), want it untouched", e.ReleaseCheckedAt) + } + + relBefore, _ := hits.counts() + got, err := SelfLatest() + if err != nil { + t.Fatalf("SelfLatest: %v", err) + } + if got != "v0.6.0" { + t.Errorf("SelfLatest = %q, want v0.6.0", got) + } + if rel, _ := hits.counts(); rel != relBefore { + t.Errorf("release requests = %d, want %d (fresh CheckedAt answers the self-check)", rel, relBefore) + } +} + +// TestSelfLatestStaleFullPassWithoutTagRefetches verifies the content check on +// the CheckedAt branch: a fresh full pass that left no tag is not an answer, so +// the self-check still makes its own request. +func TestSelfLatestFreshFullPassWithoutTagRefetches(t *testing.T) { + hits := apiServer(t, apiHandlers{release: releaseJSON("v0.7.0")}) + + // A full pass may stamp CheckedAt with an empty Latest; that alone must not + // short-circuit the release-only pass. + updateCacheEntry(SelfRepo, func(e CacheEntry) CacheEntry { + e.CheckedAt = time.Now() + e.About = "tracker" + return e + }) + + got, err := SelfLatest() + if err != nil { + t.Fatalf("SelfLatest: %v", err) + } + if got != "v0.7.0" { + t.Errorf("SelfLatest = %q, want v0.7.0", got) + } + if rel, _ := hits.counts(); rel != 1 { + t.Errorf("release requests = %d, want 1 (a tag-less fresh entry is not an answer)", rel) + } +} + +// TestSelfLatestNoReleases verifies a 404 is conclusive: empty tag, nil error, +// ReleaseCheckedAt stamped, and no re-probe on the next call. +func TestSelfLatestNoReleases(t *testing.T) { + hits := apiServer(t, apiHandlers{release: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }}) + + got, err := SelfLatest() + if err != nil { + t.Fatalf("SelfLatest: %v, want a conclusive no-release answer", err) + } + if got != "" { + t.Errorf("SelfLatest = %q, want empty", got) + } + entry := LoadCache()[SelfRepo] + if entry.ReleaseCheckedAt.IsZero() { + t.Error("ReleaseCheckedAt is zero, want stamped (a repo without releases must not be re-probed)") + } + if !entry.CheckedAt.IsZero() { + t.Errorf("CheckedAt = %v, want untouched", entry.CheckedAt) + } + + if _, err := SelfLatest(); err != nil { + t.Fatalf("second SelfLatest: %v", err) + } + if rel, _ := hits.counts(); rel != 1 { + t.Errorf("release requests = %d, want 1 (the negative is cached)", rel) + } +} + +// TestSelfLatestDroppedReleaseKeepsSharedTuple pins the 404 contract: the answer +// is conclusively "no release" and is remembered as ReleaseMissing, but the +// release tuple a tracked keeptui's card shows (latest:, its date, the ↑ marker, +// the changelog body, the clickable release URL) survives untouched — the same +// thing getRepoData and getChangelog do with the identical 404. The banner is +// silenced by the flag, not by destroying another feature's content. +func TestSelfLatestDroppedReleaseKeepsSharedTuple(t *testing.T) { + apiServer(t, apiHandlers{release: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }}) + url := "https://github.com/" + SelfRepo + "/releases/tag/v0.5.0" + updateCacheEntry(SelfRepo, func(e CacheEntry) CacheEntry { + e.Latest = "v0.5.0" + e.Body = "release notes" + e.HtmlUrl = url + e.PublishedAt = "2026-07-01T00:00:00Z" + e.About = "tracker" + return e + }) + + got, err := SelfLatest() + if err != nil { + t.Fatalf("SelfLatest: %v", err) + } + if got != "" { + t.Errorf("SelfLatest = %q, want empty once the release is gone", got) + } + entry := LoadCache()[SelfRepo] + if entry.Latest != "v0.5.0" || entry.Body != "release notes" || entry.HtmlUrl != url || entry.PublishedAt == "" { + t.Errorf("release tuple = %q/%q/%q/%q, want it preserved for the card", + entry.Latest, entry.Body, entry.HtmlUrl, entry.PublishedAt) + } + if !entry.ReleaseMissing { + t.Error("ReleaseMissing = false, want the negative recorded outside the tuple") + } + if entry.About != "tracker" { + t.Errorf("About = %q, want the card fields preserved", entry.About) + } + + // The next call inside the TTL is served from that stamp and must agree — the + // preserved tag must not come back as an update offer. + if again, err := SelfLatest(); err != nil || again != "" { + t.Errorf("second SelfLatest = %q, %v; want the cached negative", again, err) + } +} + +// TestSelfLatestNegativeSharedWithFullPass verifies the two passes agree on what a +// 404 means: whichever one observes it records ReleaseMissing, and whichever one +// later fetches a release clears it. Otherwise the self-check would either offer a +// tag the full pass only preserved for the card, or stay silent for a whole TTL +// after the release came back. +func TestSelfLatestNegativeSharedWithFullPass(t *testing.T) { + var missing bool + hits := apiServer(t, apiHandlers{release: func(w http.ResponseWriter, r *http.Request) { + if missing { + w.WriteHeader(http.StatusNotFound) + return + } + releaseJSON("v0.9.0")(w, r) + }}) + + // A full pass with a tag, then the release disappears and the full pass runs + // again: it keeps the tuple (that is its contract) and records the negative. + if d := GetRepoData("github.com/" + SelfRepo); d.Latest != "v0.9.0" { + t.Fatalf("setup GetRepoData: Latest = %q, want v0.9.0", d.Latest) + } + missing = true + if d := RefreshRepoData("github.com/" + SelfRepo); d.Latest != "v0.9.0" { + t.Fatalf("RefreshRepoData: Latest = %q, want the tuple preserved on a 404", d.Latest) + } + if e := LoadCache()[SelfRepo]; !e.ReleaseMissing { + t.Fatal("full pass 404 left ReleaseMissing false, want the negative recorded") + } + + relBefore, _ := hits.counts() + got, err := SelfLatest() + if err != nil || got != "" { + t.Errorf("SelfLatest = %q, %v; want the shared negative, not the preserved tag", got, err) + } + if rel, _ := hits.counts(); rel != relBefore { + t.Errorf("release requests = %d, want %d (the fresh full pass answers)", rel, relBefore) + } + + // The release comes back: the full pass clears the negative, so the self-check + // offers the tag again without waiting out the TTL. + missing = false + if d := RefreshRepoData("github.com/" + SelfRepo); d.Latest != "v0.9.0" { + t.Fatalf("RefreshRepoData: Latest = %q, want v0.9.0", d.Latest) + } + if got, err := SelfLatest(); err != nil || got != "v0.9.0" { + t.Errorf("SelfLatest = %q, %v; want v0.9.0 once the release is back", got, err) + } +} + +// TestSelfLatestNegativeClearedByChangelogFetch covers the third writer of the +// release tuple: a changelog fetch that lands a release also retires a remembered +// "no release" negative, so the self-check reads the entry the same way whichever +// pass filled it. +func TestSelfLatestNegativeClearedByChangelogFetch(t *testing.T) { + hits := apiServer(t, apiHandlers{release: releaseJSON("v1.2.3")}) + + // A negative from an expired window: its own stamp no longer answers, so the + // CheckedAt branch — the one the changelog fetch stamps — decides. + updateCacheEntry(SelfRepo, func(e CacheEntry) CacheEntry { + e.ReleaseMissing = true + e.ReleaseCheckedAt = time.Now().Add(-2 * cacheTTL) + return e + }) + + if _, err := GetChangelog("github.com/" + SelfRepo); err != nil { + t.Fatalf("setup GetChangelog: %v", err) + } + relBefore, _ := hits.counts() + + got, err := SelfLatest() + if err != nil || got != "v1.2.3" { + t.Errorf("SelfLatest = %q, %v; want v1.2.3 — the fetched release retires the negative", got, err) + } + if rel, _ := hits.counts(); rel != relBefore { + t.Errorf("release requests = %d, want %d (the fresh entry answers)", rel, relBefore) + } +} + +// TestSelfLatestNegativeRecordedByChangelog404 closes the third writer's other +// direction: a changelog fetch that answers 404 must *record* the negative, not +// only clear it on success. This is the one window where no other pass can: +// CheckedAt is fresh (so getRepoData short-circuits and never asks) while Body is +// empty (a release published without notes), which makes the changelog the only +// pass still reaching /releases/latest. Without the write, a release deleted or +// converted to a draft would go unobserved and the self-check's CheckedAt branch +// would keep offering the preserved tag for the rest of the TTL. +func TestSelfLatestNegativeRecordedByChangelog404(t *testing.T) { + hits := apiServer(t, apiHandlers{release: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }}) + + // A fresh full pass that left a tag but no body — the changelog's own gate + // (fresh CheckedAt *and* a non-empty Body) is what sends it to the network. + checkedAt := time.Now() + updateCacheEntry(SelfRepo, func(e CacheEntry) CacheEntry { + e.Latest = "v0.5.0" + e.HtmlUrl = "https://github.com/" + SelfRepo + "/releases/tag/v0.5.0" + e.PublishedAt = "2026-07-01T00:00:00Z" + e.CheckedAt = checkedAt + return e + }) + + info, err := GetChangelog("github.com/" + SelfRepo) + if err != nil { + t.Fatalf("GetChangelog: %v", err) + } + if info.Tag != "v0.5.0" { + t.Errorf("GetChangelog Tag = %q, want the cached tuple served on a 404", info.Tag) + } + if rel, _ := hits.counts(); rel != 1 { + t.Fatalf("release requests = %d, want 1 (the empty Body must force the fetch)", rel) + } + + entry := LoadCache()[SelfRepo] + if !entry.ReleaseMissing { + t.Error("ReleaseMissing = false, want the changelog's own 404 recorded") + } + if entry.Latest != "v0.5.0" || entry.HtmlUrl == "" || entry.PublishedAt == "" { + t.Errorf("release tuple = %q/%q/%q, want it preserved for the card", + entry.Latest, entry.HtmlUrl, entry.PublishedAt) + } + if !entry.CheckedAt.Equal(checkedAt) { + t.Errorf("CheckedAt = %v, want %v (the flag write must not restamp freshness)", entry.CheckedAt, checkedAt) + } + + // The self-check reads that entry through the still-fresh CheckedAt branch: + // the flag, not the preserved tag, is the answer. + got, err := SelfLatest() + if err != nil || got != "" { + t.Errorf("SelfLatest = %q, %v; want the recorded negative, not the preserved tag", got, err) + } + if rel, _ := hits.counts(); rel != 1 { + t.Errorf("release requests = %d, want 1 (the fresh entry answers)", rel) + } +} + +// TestSelfLatestErrors verifies transient failures surface classified and stamp +// nothing, so the next launch retries instead of going quiet for a whole TTL. +func TestSelfLatestErrors(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantErr error + }{ + { + name: "rate limited", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.WriteHeader(http.StatusForbidden) + }, + wantErr: ErrRateLimited, + }, + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hits := apiServer(t, apiHandlers{release: tt.handler}) + + got, err := SelfLatest() + if err == nil { + t.Fatalf("SelfLatest = %q, nil; want an error", got) + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Errorf("err = %v, want %v", err, tt.wantErr) + } + if got != "" { + t.Errorf("SelfLatest = %q, want empty on failure", got) + } + if e, ok := LoadCache()[SelfRepo]; ok && !e.ReleaseCheckedAt.IsZero() { + t.Errorf("ReleaseCheckedAt = %v, want unstamped after a failed fetch", e.ReleaseCheckedAt) + } + + // The failure left nothing behind, so the next call retries. + _, _ = SelfLatest() + if rel, _ := hits.counts(); rel != 2 { + t.Errorf("release requests = %d, want 2 (a failure must not suppress the retry)", rel) + } + }) + } +} + +// TestSelfLatestPreservesEntry verifies the merge-on-write: the release-only +// pass must not wipe the card/README fields a tracked keeptui already has. +func TestSelfLatestPreservesEntry(t *testing.T) { + apiServer(t, apiHandlers{release: releaseJSON("v0.8.0")}) + + if d := GetRepoData("github.com/" + SelfRepo); d.About != "tracker" { + t.Fatalf("setup GetRepoData: About = %q, want tracker", d.About) + } + if _, err := GetReadme("github.com/" + SelfRepo); err != nil { + t.Fatalf("setup GetReadme: %v", err) + } + before := LoadCache()[SelfRepo] + + // Expire only the card timestamps so the self-check actually fetches. + updateCacheEntry(SelfRepo, func(e CacheEntry) CacheEntry { + e.CheckedAt = time.Now().Add(-2 * cacheTTL) + return e + }) + + if _, err := SelfLatest(); err != nil { + t.Fatalf("SelfLatest: %v", err) + } + + entry := LoadCache()[SelfRepo] + if entry.Readme != before.Readme || entry.Readme == "" { + t.Errorf("Readme = %q, want it preserved (%q)", entry.Readme, before.Readme) + } + if !entry.ReadmeCheckedAt.Equal(before.ReadmeCheckedAt) { + t.Errorf("ReadmeCheckedAt = %v, want unchanged %v", entry.ReadmeCheckedAt, before.ReadmeCheckedAt) + } + if entry.About != "tracker" || entry.Stars != 5 || len(entry.Languages) == 0 { + t.Errorf("card fields wiped: About=%q Stars=%d Languages=%v", entry.About, entry.Stars, entry.Languages) + } + if !entry.CheckedAt.Before(time.Now().Add(-cacheTTL)) { + t.Errorf("CheckedAt = %v, want left stale by a release-only pass", entry.CheckedAt) + } +} diff --git a/main.go b/main.go index a97e910..50638cd 100644 --- a/main.go +++ b/main.go @@ -132,11 +132,12 @@ func runTUI() { ver, runtime.GOOS, runtime.GOARCH, len(meta), verpkg.TokenSource())) p := tea.NewProgram( - model.New(meta), + newRootModel(meta, ver), tea.WithAltScreen(), tea.WithMouseCellMotion(), ) - if _, err := p.Run(); err != nil { + final, err := p.Run() + if err != nil { if errors.Is(err, tea.ErrProgramPanic) { logx.Errorf("tea.Run ended in panic: %v", err) } else { @@ -145,4 +146,42 @@ func runTUI() { fmt.Fprintf(os.Stderr, "error running: %v\n", err) os.Exit(1) } + restartIfRequested(final, restartSelf) +} + +// newRootModel builds the model tea.NewProgram runs. It is a named function +// rather than an expression inline in runTUI for the reason resolveSelfPath and +// shellCommand are: runTUI itself needs a terminal and cannot be tested, so +// everything it decides without one lives outside it. +// +// WithAppVersion hands the model the version of this very binary: it is what the +// self-check compares against keeptui's latest release, and a dev build +// (ver == "dev") switches the whole feature off — no request, no banner. Dropping +// it would leave the self-update feature silently dead in every shipped binary, +// which is exactly what TestNewRootModelInjectsAppVersion is there to catch. +func newRootModel(meta []loader.ToolMeta, ver string) model.Model { + return model.New(meta).WithAppVersion(ver) +} + +// restarter is the one thing runTUI reads off the model p.Run() returns. It is +// an interface rather than a model.Model type assertion so the decision below +// can be exercised with a stand-in: the flag sits in the model's unexported +// state behind a key press that needs unexported messages to become reachable, +// so from here no real model can ever answer true. The compile-time assertion +// below is what pins the real model to it — a renamed method breaks the build +// instead of the feature. +type restarter interface{ RestartRequested() bool } + +var _ restarter = model.Model{} + +// restartIfRequested runs the [U] restart the user accepted after a self-update. +// The key quits with a flag instead of exec'ing from inside Update: only here, +// after p.Run() returned, is the terminal restored and the alt screen gone, so +// the new process starts on a clean one. restart is injected — the same seam +// idiom as restartSelfWith — because the decision is testable and syscall.Exec +// is not. +func restartIfRequested(final tea.Model, restart func()) { + if r, ok := final.(restarter); ok && r.RestartRequested() { + restart() + } } diff --git a/main_test.go b/main_test.go index 144e94a..4037750 100644 --- a/main_test.go +++ b/main_test.go @@ -5,8 +5,10 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stanlyzoolo/keeptui/internal/loader" "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keeptui/internal/model" ) func TestHandleCLI(t *testing.T) { @@ -83,12 +85,86 @@ func TestResolveVersion(t *testing.T) { } } +// TestNewRootModelInjectsAppVersion: the model the shipped binary runs must +// carry this build's version — that injection is the only thing that turns the +// self-update feature on, and without it every release would ship with no +// self-check, no banner and no restart offer while the model package's own tests +// stayed green. Observed through Init's batch, which is where the version has its +// first user-visible effect: a release build queues the self-check command, a dev +// build queues nothing extra. +// +// The batch is only counted, never executed: the elements are live network +// fetches. The tool carries no GitHub ref for the same reason (it keeps Init's +// remote seeds out of the batch) and exists at all so the batch always holds two +// or more commands — tea.Batch hands back a single command unwrapped, and this +// asserts on the tea.BatchMsg. +func TestNewRootModelInjectsAppVersion(t *testing.T) { + meta := []loader.ToolMeta{{Name: "localtool"}} + batchLen := func(ver string) int { + msg := newRootModel(meta, ver).Init()() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("Init() with version %q produced %T, want a tea.BatchMsg", ver, msg) + } + return len(batch) + } + dev := batchLen("dev") + if got := batchLen("v0.4.2"); got != dev+1 { + t.Errorf("Init queued %d cmds on a release build and %d on a dev build, want exactly one more (the self-check) — is WithAppVersion still wired?", got, dev) + } +} + +// TestRestartIfRequested pins the other half of the production wiring: the model +// p.Run() returns decides whether keeptui re-execs itself. A stand-in supplies +// the flag because model.Model can only reach it through unexported state. +func TestRestartIfRequested(t *testing.T) { + tests := []struct { + name string + final tea.Model + want bool + }{ + {name: "restart requested", final: fakeFinal{restart: true}, want: true}, + {name: "quit without the restart flag", final: fakeFinal{}}, + {name: "a real model carries no flag by default", final: model.New(nil)}, + {name: "a model that cannot answer at all", final: plainFinal{}}, + {name: "no final model"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + restartIfRequested(tt.final, func() { called = true }) + if called != tt.want { + t.Errorf("restart called = %v, want %v", called, tt.want) + } + }) + } +} + +// fakeFinal stands in for the model p.Run() returns after [U] restart. +type fakeFinal struct{ restart bool } + +func (f fakeFinal) Init() tea.Cmd { return nil } +func (f fakeFinal) Update(tea.Msg) (tea.Model, tea.Cmd) { return f, nil } +func (f fakeFinal) View() string { return "" } +func (f fakeFinal) RestartRequested() bool { return f.restart } + +// plainFinal is a tea.Model with no restart flag at all — the `ok` half of the +// type assertion, which must not restart. +type plainFinal struct{} + +func (p plainFinal) Init() tea.Cmd { return nil } +func (p plainFinal) Update(tea.Msg) (tea.Model, tea.Cmd) { return p, nil } +func (p plainFinal) View() string { return "" } + // TestMain isolates the files this package can reach for the whole test binary: // main calls loader.LoadMeta, and a test here must never touch the developer's // real tracker. Mirrors the TestMain in internal/loader, internal/model and -// internal/version. (The version package's cache/token are reached only through -// the model, which isolates them in its own TestMain; importing it here would -// collide with main.go's `version` ldflag variable.) +// internal/version. (The version package's cache/token are not redirected here: +// nothing in this package reaches them, because the two tests that build a model +// only construct it and count Init's batch — no model command is ever executed. +// A test that does execute one has to redirect them itself, aliasing the import +// the way main.go does: a plain `version` name would collide with main.go's +// ldflag variable.) func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "keeptui-main-logs") if err != nil { diff --git a/restart.go b/restart.go new file mode 100644 index 0000000..e2dcecd --- /dev/null +++ b/restart.go @@ -0,0 +1,10 @@ +package main + +// restartHint is what keeptui prints when it cannot replace itself with the +// updated binary: always on Windows, where there is no exec, and on unix when +// path resolution or syscall.Exec failed. The update itself already succeeded, +// so this is an instruction to the user, not an error report. +// +// Shared by both platforms' restartSelf, which is why it lives in the untagged +// file — everything that actually resolves and execs a path is unix-only. +const restartHint = "keeptui updated — run keeptui again" diff --git a/restart_unix.go b/restart_unix.go new file mode 100644 index 0000000..9d235c6 --- /dev/null +++ b/restart_unix.go @@ -0,0 +1,117 @@ +//go:build !windows + +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "github.com/stanlyzoolo/keeptui/internal/logx" +) + +// restartSelf replaces this process with the freshly updated binary, so the +// terminal tab or tmux pane keeptui was launched in survives the restart: same +// pid, same argv, same environment. Called from runTUI strictly after p.Run() +// returned — Bubble Tea has restored the terminal by then, and exec'ing from +// inside Update would hand the new process an alt screen it never opened. +// On success it does not return. +func restartSelf() { + restartSelfWith(selfPath, syscall.Exec, os.Stdout) +} + +// restartSelfWith is restartSelf's testable core: the path resolver, the exec +// call and the hint's destination are injected (the same seam idiom as +// resolveSelfPath's exists/lookPath). +// +// A failure here is not a failed update: the new binary is on disk either way, +// so the honest outcome is a plain exit plus restartHint on stdout, with a zero +// status — it is an instruction, not a diagnostic. The failure is still an +// anomaly worth researching after the fact, hence the log line. +func restartSelfWith(resolve func() (string, error), execve func(string, []string, []string) error, out io.Writer) { + path, err := resolve() + if err == nil { + // Returns only on failure: a successful exec replaces this image, so + // nothing below runs. + err = execve(path, os.Args, os.Environ()) + } + if err == nil { + return + } + logx.Errorf("restart self: %v", err) + _, _ = fmt.Fprintln(out, restartHint) +} + +// resolveSelfPath picks which binary to exec on restart. It mirrors how a shell +// resolves the command the user typed instead of trusting os.Executable, and the +// order is load-bearing on Linux: there os.Executable() reads /proc/self/exe and +// comes back symlink-*resolved*, so after a keg-style upgrade it can still name +// the live *old* binary — exec'ing that would loop the feature ("restart" → the +// same banner → "restart"). A bare argv0 therefore goes through lookPath first, +// which finds whatever the upgrade put on PATH. An argv0 carrying a path +// separator is already a path the user pointed at and is used as-is when it +// exists. executable is the fallback for both cases, and only when it exists — +// otherwise there is nothing safe to exec and the caller prints restartHint. +// +// A PATH hit is only trusted when it names the same program as executable: +// argv0 is set by the parent process, so a wrapper (exec -a) or a shell that +// rewrote argv[0] would otherwise make the restart exec an unrelated binary with +// keeptui's argv and environment. +func resolveSelfPath(executable string, exists func(string) bool, lookPath func(string) (string, error), argv0 string) (string, error) { + if argv0 != "" { + if strings.Contains(argv0, "/") { + if exists(argv0) { + return argv0, nil + } + } else if p, err := lookPath(argv0); err == nil && p != "" && sameProgram(p, executable) { + return p, nil + } + } + if executable != "" && exists(executable) { + return executable, nil + } + return "", fmt.Errorf("no keeptui binary to restart (argv0 %q, executable %q)", argv0, executable) +} + +// sameProgram reports whether two paths name the same program by base name. An +// unknown executable — os.Executable() failed — cannot contradict anything, so +// it accepts. +// +// filepath.Base is the whole comparison because this file is unix-only: the +// windows-style separator and .exe handling it used to carry dated from when the +// core lived in the untagged restart.go, and under //go:build !windows neither +// can fire — exec.LookPath("keeptui") on unix never answers "keeptui.exe", and a +// backslash in argv0 is an ordinary filename character, not a separator. +func sameProgram(a, b string) bool { + if b == "" { + return true + } + return filepath.Base(a) == filepath.Base(b) +} + +// selfPath is the OS-facing wrapper over resolveSelfPath (the shellCommand / +// planFor idiom: pure core, thin wrapper). +func selfPath() (string, error) { + exe, err := os.Executable() + if err != nil { + // Nothing to fall back on, but argv0 may still resolve. + exe = "" + } + argv0 := "" + if len(os.Args) > 0 { + argv0 = os.Args[0] + } + return resolveSelfPath(exe, fileExists, exec.LookPath, argv0) +} + +// fileExists is resolveSelfPath's exists probe. A path that stats is good +// enough — anything finer (not executable, wrong architecture) is reported by +// the exec itself, and both outcomes land on the same restartHint. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/restart_unix_test.go b/restart_unix_test.go new file mode 100644 index 0000000..92fb737 --- /dev/null +++ b/restart_unix_test.go @@ -0,0 +1,282 @@ +//go:build !windows + +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stanlyzoolo/keeptui/internal/logx" +) + +// TestResolveSelfPath pins the argv0 semantics of the restart path resolution. +// The load-bearing case is the first one: a bare argv0 must go through PATH even +// when os.Executable() named an existing file, because on Linux that file is the +// symlink-resolved /proc/self/exe and can still be the pre-upgrade binary — a +// restart into it would loop the banner forever. +func TestResolveSelfPath(t *testing.T) { + notFound := errors.New("not found in $PATH") + tests := []struct { + name string + executable string + argv0 string + existing []string + pathHit string // lookPath result; empty means a miss + pathHitNoErr bool // return ("", nil) instead of ("", notFound) + want string + wantLookedUp string // the name lookPath must have been asked for + wantErr bool + }{ + { + name: "bare argv0 prefers PATH over a live executable", + executable: "/old/keg/bin/keeptui", + argv0: "keeptui", + existing: []string{"/old/keg/bin/keeptui", "/usr/local/bin/keeptui"}, + pathHit: "/usr/local/bin/keeptui", + want: "/usr/local/bin/keeptui", + wantLookedUp: "keeptui", + }, + { + name: "lookPath miss falls back to the executable", + executable: "/usr/local/bin/keeptui", + argv0: "keeptui", + existing: []string{"/usr/local/bin/keeptui"}, + want: "/usr/local/bin/keeptui", + wantLookedUp: "keeptui", + }, + { + name: "lookPath returning an empty path with no error is a miss", + executable: "/usr/local/bin/keeptui", + argv0: "keeptui", + existing: []string{"/usr/local/bin/keeptui"}, + pathHitNoErr: true, + want: "/usr/local/bin/keeptui", + wantLookedUp: "keeptui", + }, + { + // The empty-path check on its own: with no executable to compare + // against, sameProgram accepts anything, so only `p != ""` stops the + // empty string from being returned as a path to syscall.Exec. + name: "an empty lookPath hit is never a path", + argv0: "keeptui", + pathHitNoErr: true, + wantLookedUp: "keeptui", + wantErr: true, + }, + { + name: "a PATH hit for a rewritten argv0 is rejected", + executable: "/usr/local/bin/keeptui", + argv0: "kt-wrapper", + existing: []string{"/usr/local/bin/keeptui", "/usr/bin/kt-wrapper"}, + pathHit: "/usr/bin/kt-wrapper", + want: "/usr/local/bin/keeptui", + wantLookedUp: "kt-wrapper", + }, + { + name: "argv0 with a separator wins when it exists", + executable: "/proc/self/exe/resolved/keeptui", + argv0: "./bin/keeptui", + existing: []string{"./bin/keeptui", "/proc/self/exe/resolved/keeptui"}, + pathHit: "/usr/local/bin/keeptui", + want: "./bin/keeptui", + }, + { + name: "vanished argv0 path falls back to the executable", + executable: "/usr/local/bin/keeptui", + argv0: "/old/build/keeptui", + existing: []string{"/usr/local/bin/keeptui"}, + pathHit: "/usr/local/bin/keeptui", // never consulted for a path argv0 + want: "/usr/local/bin/keeptui", + }, + { + name: "everything missed is an error", + executable: "/gone/keeptui", + argv0: "/also/gone/keeptui", + wantErr: true, + wantLookedUp: "", + }, + { + name: "no executable and no argv0", + wantErr: true, + }, + { + name: "empty argv0 still uses the executable", + executable: "/usr/local/bin/keeptui", + existing: []string{"/usr/local/bin/keeptui"}, + want: "/usr/local/bin/keeptui", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exists := func(p string) bool { + for _, e := range tt.existing { + if e == p { + return true + } + } + return false + } + lookedUp := "" + lookPath := func(name string) (string, error) { + lookedUp = name + if tt.pathHit == "" { + if tt.pathHitNoErr { + return "", nil + } + return "", notFound + } + return tt.pathHit, nil + } + got, err := resolveSelfPath(tt.executable, exists, lookPath, tt.argv0) + // PATH must be searched for argv0 itself, never for a hardcoded name. + if lookedUp != tt.wantLookedUp { + t.Errorf("lookPath called with %q, want %q", lookedUp, tt.wantLookedUp) + } + if tt.wantErr { + if err == nil { + t.Fatalf("resolveSelfPath = %q, want an error", got) + } + if got != "" { + t.Errorf("resolveSelfPath returned %q alongside an error, want empty", got) + } + return + } + if err != nil { + t.Fatalf("resolveSelfPath: unexpected error %v", err) + } + if got != tt.want { + t.Errorf("resolveSelfPath = %q, want %q", got, tt.want) + } + }) + } +} + +// TestResolveSelfPathNeverLooksUpAPath: an argv0 carrying a separator is already +// a path, so PATH is not consulted for it — a same-named binary earlier in PATH +// must not hijack a restart the user started with ./keeptui. +func TestResolveSelfPathNeverLooksUpAPath(t *testing.T) { + called := false + lookPath := func(string) (string, error) { + called = true + return "/usr/local/bin/keeptui", nil + } + got, err := resolveSelfPath("/fallback/keeptui", func(string) bool { return true }, lookPath, "./keeptui") + if err != nil { + t.Fatalf("resolveSelfPath: %v", err) + } + if got != "./keeptui" { + t.Errorf("resolveSelfPath = %q, want ./keeptui", got) + } + if called { + t.Error("lookPath was consulted for an argv0 that is already a path") + } +} + +// TestFileExists covers the real-filesystem probe handed to resolveSelfPath. +func TestFileExists(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "keeptui") + if err := os.WriteFile(file, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + if !fileExists(file) { + t.Errorf("fileExists(%q) = false, want true", file) + } + if fileExists(filepath.Join(dir, "absent")) { + t.Error("fileExists on a missing path = true, want false") + } +} + +// TestSelfPathResolves: the wrapper finds something to exec in the test binary's +// own environment (argv0 is the test binary, which exists as a path), so the +// os.Executable/LookPath/os.Args plumbing is not miswired. +func TestSelfPathResolves(t *testing.T) { + got, err := selfPath() + if err != nil { + t.Fatalf("selfPath: %v", err) + } + if got == "" { + t.Error("selfPath returned an empty path with no error") + } +} + +// TestRestartSelfWith covers both outcomes of the restart: a successful exec +// never comes back, and either failure (resolve or exec) prints the hint that +// says the update did land and logs the anomaly. +func TestRestartSelfWith(t *testing.T) { + tests := []struct { + name string + resolve func() (string, error) + execErr error + wantExec bool + wantLog string + }{ + { + name: "exec fails", + resolve: func() (string, error) { return "/usr/local/bin/keeptui", nil }, + execErr: errors.New("exec format error"), + wantExec: true, + wantLog: "exec format error", + }, + { + name: "path resolution fails", + resolve: func() (string, error) { return "", errors.New("no keeptui binary to restart") }, + wantLog: "no keeptui binary to restart", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logDir := t.TempDir() + restore := logx.SetDirForTesting(logDir) + defer restore() + + execed := "" + var out bytes.Buffer + restartSelfWith(tt.resolve, func(path string, _, _ []string) error { + execed = path + return tt.execErr + }, &out) + + if tt.wantExec && execed != "/usr/local/bin/keeptui" { + t.Errorf("exec'd %q, want the resolved path", execed) + } + if !tt.wantExec && execed != "" { + t.Errorf("exec'd %q after a failed resolve, want no exec at all", execed) + } + // The wording is pinned here rather than against the const: this is + // where the user actually reads it, and it must say the update landed. + if got := strings.TrimSpace(out.String()); got != "keeptui updated — run keeptui again" { + t.Errorf("printed %q, want the restart hint", got) + } + if log := logx.ReadAllForTesting(logDir); !strings.Contains(log, tt.wantLog) { + t.Errorf("log = %q, want it to record %q", log, tt.wantLog) + } + }) + } +} + +// TestRestartSelfWithSuccessIsSilent: a successful exec replaces the image, so +// nothing after it runs — no hint, no log line. The stub returns nil to stand in +// for "did not come back". +func TestRestartSelfWithSuccessIsSilent(t *testing.T) { + logDir := t.TempDir() + restore := logx.SetDirForTesting(logDir) + defer restore() + + var out bytes.Buffer + restartSelfWith( + func() (string, error) { return "/usr/local/bin/keeptui", nil }, + func(string, []string, []string) error { return nil }, + &out, + ) + if log := logx.ReadAllForTesting(logDir); log != "" { + t.Errorf("log = %q, want nothing logged on a successful exec", log) + } + if out.Len() != 0 { + t.Errorf("printed %q, want nothing on a successful exec", out.String()) + } +} diff --git a/restart_windows.go b/restart_windows.go new file mode 100644 index 0000000..086f57f --- /dev/null +++ b/restart_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package main + +import "fmt" + +// restartSelf degrades honestly on Windows: there is no exec that replaces the +// running image, and spawning a child from a process that is about to exit +// hands the new keeptui a console the old one still owns. Printing the hint and +// exiting is the whole restart here — no spawn, no path resolution. +func restartSelf() { + fmt.Println(restartHint) +}