diff --git a/.gitignore b/.gitignore index b09047b..e766e1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +keepkit +keepkit.exe keeptui +keeptui.exe .claude dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1e07880..ccf52d7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,23 +3,23 @@ # Run locally with `goreleaser release --snapshot --clean` to dry-run (no publish). version: 2 -project_name: keeptui +project_name: keepkit before: hooks: - go mod tidy builds: - - id: keeptui + - id: keepkit main: . - binary: keeptui + binary: keepkit env: - CGO_ENABLED=0 flags: - -trimpath - -buildvcs=false - # {{ .Tag }} keeps the leading "v" (e.g. v1.2.3) so `keeptui --version` - # output stays identical to the pre-GoReleaser releases and a keeptui + # {{ .Tag }} keeps the leading "v" (e.g. v1.2.3) so `keepkit --version` + # output stays identical to the pre-GoReleaser releases and a keepkit # tracking itself still parses its own version. ldflags: - -s -w -X main.version={{ .Tag }} @@ -36,7 +36,7 @@ builds: goarch: arm64 archives: - - id: keeptui + - id: keepkit # umputun-style names: darwin→macos, amd64→x86_64 (cosmetic; the URLs # in the generated formula follow whatever this produces). name_template: >- @@ -61,23 +61,23 @@ changelog: release: github: owner: stanlyzoolo - name: keeptui + name: keepkit brews: - - name: keeptui + - name: keepkit repository: owner: stanlyzoolo name: homebrew-apps # PAT with write access to the tap repo, set as a secret in THIS repo. token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" directory: Formula - homepage: "https://github.com/stanlyzoolo/keeptui" - description: "Terminal TUI tracker for CLI tools" + homepage: "https://github.com/stanlyzoolo/keepkit" + description: "Lightweight TUI for tracking versions of your favorite tools" license: MIT commit_author: name: goreleaser-bot email: bot@goreleaser.com install: | - bin.install "keeptui" + bin.install "keepkit" test: | - system "#{bin}/keeptui", "--version" + system "#{bin}/keepkit", "--version" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 650ae4b..fba8f6c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,12 +1,12 @@ # Architecture -`keeptui` is a terminal TUI tracker for CLI tools built with [Bubble Tea](https://github.com/charmbracelet/bubbletea). +`keepkit` 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).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 +before the TUI starts, so `keepkit` 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 @@ -47,7 +47,7 @@ graph TD | Package | Responsibility | |---|---| -| `internal/configdir` | Resolve the base user-config dir: `~/.config/keeptui` on macOS/Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Pure `baseFor(goos, …)` core + `Base()` wrapper; stdlib-only bottom leaf shared by `loader`/`version`/`logx`/`main` | +| `internal/configdir` | Resolve the base user-config dir: `~/.config/keepkit` on macOS/Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keepkit` on Windows. Pure `baseFor(goos, …)` core + `Base()` wrapper; stdlib-only bottom leaf shared by `loader`/`version`/`logx`/`main` | | `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` → `Plan{Argv, Fallback, Terminal}`, detection chain tmux → iTerm2 → Terminal.app → kitty → WezTerm → fallback; env-only, no subprocesses | | `internal/loader` | Tracker persistence (`meta.yaml`), status lifecycle (`active → trying → inactive`, legacy values migrated on read), the one-tag-per-tool invariant (a legacy multi-tag list is truncated to its first entry on read), GitHub ref parsing (`NormalizeRepo`, `ParseToolRef`) | | `internal/logx` | Session error journal: errors only, one lazily created file per session, imports only the stdlib-only `configdir` leaf. Package-level state — any package can log without threading a logger through | @@ -55,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`); keeptui's own release check (`SelfRepo`, `SelfLatest`) | +| `internal/version` | Detect the installed version locally — `InstalledVersion(t) (ver, present)`; GitHub API with a 24-hour cache; semver comparison (`IsNewer`); keepkit'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 @@ -77,7 +77,7 @@ The `model` package is split across files within a single package: ## Data flow -1. `loader.LoadMeta()` reads `~/.config/keeptui/meta.yaml` — the single source of +1. `loader.LoadMeta()` reads `~/.config/keepkit/meta.yaml` — the single source of tracked tools. 2. `model.New(meta)` builds the model; `Init()` fires the async fetches — results arrive as messages and are merged into the state. @@ -112,13 +112,13 @@ 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 +`selfCheckCmd` (keepkit'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 -the `keeptui` screen. The protection has two layers: +the `keepkit` screen. The protection has two layers: 1. every probe goes through `proc.DetachTTY` — its own session, no controlling terminal: the child's attempt to open `/dev/tty` gets `ENXIO` instead of toggling @@ -187,7 +187,7 @@ Key invariants: 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 + selection (a self-update — keepkit 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 @@ -217,8 +217,8 @@ lives in `[3] Update` (a ~500-line buffer); the 10-minute deadline ends with ## 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 +keepkit watches its own releases and installs one through the same pipeline as `[u]`. +**The main case is a keepkit 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` @@ -236,11 +236,11 @@ 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, +site that asks "is this keepkit'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 +tracked `keepkit` 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 @@ -255,16 +255,16 @@ Detection, the confirm dialog and the streaming log are the `[u]` machinery, and 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 +handler — keys off that name instead of `selectedMeta()`, which an untracked keepkit (or +an empty tracker) cannot answer. So an update of keepkit is a self-update whichever key +started it, `u` on a tracked keepkit 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 — +lookup whose early return would otherwise drop the message for an untracked keepkit — 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 @@ -284,7 +284,7 @@ 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 +degrades to printing `keepkit updated — run keepkit again` and exiting; the same hint covers a unix resolution or exec failure (on stdout, exit code 0 — the update did succeed). @@ -298,7 +298,7 @@ runs its argv as a `tea.Cmd` through `proc.DetachTTY` with a 10-second ceiling (`proc.KillGroup` on the process group when it fires — mostly for osascript blocked on the macOS Automation dialog); a `Fallback` plan — terminals with no scripting API, and native Windows — runs the command in the current window via -`tea.ExecProcess` (`sh -c` / `cmd /c`): keeptui suspends and resumes when the +`tea.ExecProcess` (`sh -c` / `cmd /c`): keepkit suspends and resumes when the tool exits. An adapter failure **auto-falls back** to `tea.ExecProcess`, so the tool still @@ -309,7 +309,7 @@ deferred, not dropped: it fires — with a visible status message — on the keystroke that closes the mode, going straight to the exec fallback (the failing adapter plan is never re-run). One adapter launch runs at a time (`m.launchingFor`, the launch twin of `updatingFor`). Working directories differ by path: a tab opens -in the new shell's default cwd, the fallback inherits keeptui's. A non-zero +in the new shell's default cwd, the fallback inherits keepkit's. A non-zero exit of the tool itself is a status message only — never logged. ## GitHub API @@ -319,9 +319,9 @@ costs 3 requests at startup, plus one lazy request for the README of the tool op 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). 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 +per 24-hour window; a tracked keepkit 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 +`~/.config/keepkit/token` file (`0600`); a token entered in the TUI is validated with a `/rate_limit` request before being written to disk. - **`doGH(req)`** — the single auth point: headers, the 5-second client, reading the @@ -346,22 +346,22 @@ makes the check free. Token: `GITHUB_TOKEN` from the environment always wins ove 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 + banner goes quiet while a tracked keepkit 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 -The base dir comes from `configdir.Base()`: `~/.config/keeptui` on macOS and Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Paths below use the macOS/Linux form. +The base dir comes from `configdir.Base()`: `~/.config/keepkit` on macOS and Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keepkit` on Windows. Paths below use the macOS/Linux form. | Data | Path | |---|---| -| Tracker metadata | `~/.config/keeptui/meta.yaml` | -| 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` | +| Tracker metadata | `~/.config/keepkit/meta.yaml` | +| Version, README and self-check cache (24h TTL each, separate timestamps) | `~/.config/keepkit/cache.json` | +| GitHub token (`0600`) | `~/.config/keepkit/token` | +| Session error log | `~/.config/keepkit/logs/keepkit-.log` | +| Pre-migration tracker copy (written once, when a load dropped tags) | `~/.config/keepkit/meta.yaml.bak` | `SaveMeta` writes atomically (temp file + `os.Rename` in the same directory) — a crash mid-write can never truncate `meta.yaml`. diff --git a/CLAUDE.md b/CLAUDE.md index b3db973..b2054ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash go build . # build binary -go install . # install to ~/go/bin/keeptui +go install . # install to ~/go/bin/keepkit go run . # run without installing go test -race ./... # run all tests (the version package has real mutex-guarded state — keep -race) go vet ./... # static analysis @@ -17,11 +17,11 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint ## Architecture -**`keeptui`** is a terminal TUI tracker for CLI tools built with Bubble Tea. It is a pure TUI app — running `keeptui` launches the interface directly; the only CLI surface is `--version`/`--help`. +**`keepkit`** is a terminal TUI tracker for CLI tools built with Bubble Tea. It is a pure TUI app — running `keepkit` launches the interface directly; the only CLI surface is `--version`/`--help`. ### 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).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. +`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 keepkit'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 `keepkit ` — dotted-numeric on release/`go install` builds, so `version.InstalledVersion`'s regex parses it and a keepkit tracked inside keepkit 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 keepkit 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. @@ -29,15 +29,15 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint | Package | Purpose | |---|---| -| `internal/configdir` | Resolve keeptui's base user-config dir (parent of the `keeptui/` subdir holding `meta.yaml`, `cache.json`, `token`, `logs/`). Pure `baseFor(goos, getenv, userConfigDir, userHomeDir)` core + thin `Base()` wrapper (the `shellCommand`/`planFor` idiom). **Windows** → `os.UserConfigDir()` (`%AppData%`); **macOS and Linux** → `$XDG_CONFIG_HOME` else `~/.config` — deliberately *not* `os.UserConfigDir()`, which is `~/Library/Application Support` on macOS. The **bottom leaf of the import graph** (stdlib only), so `loader`/`version`/`logx`/`main` all share one resolution without a cycle | +| `internal/configdir` | Resolve keepkit's base user-config dir (parent of the `keepkit/` subdir holding `meta.yaml`, `cache.json`, `token`, `logs/`). Pure `baseFor(goos, getenv, userConfigDir, userHomeDir)` core + thin `Base()` wrapper (the `shellCommand`/`planFor` idiom). **Windows** → `os.UserConfigDir()` (`%AppData%`); **macOS and Linux** → `$XDG_CONFIG_HOME` else `~/.config` — deliberately *not* `os.UserConfigDir()`, which is `~/Library/Application Support` on macOS. The **bottom leaf of the import graph** (stdlib only), so `loader`/`version`/`logx`/`main` all share one resolution without a cycle | | `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` over an injected env lookup → `Plan{Argv, Fallback, Terminal}`, thin `Detect` wrapper over `os.Getenv`. Detection chain (first hit wins): `$TMUX` → iTerm2 → Terminal.app → kitty → WezTerm → `Fallback: true` (no scripting API — run in the current window). `$TMUX` is deliberately first: inside tmux `TERM_PROGRAM` names the *outer* terminal and a tmux window is the correct "tab" there. Terminal.app opens a **window**, not a tab (tabs aren't scriptable without System Events — honest degradation). tmux/kitty/wezterm plans carry command and tool name as argv elements (no escaping; the tmux plan puts `--` before the command so a `-`-leading edit isn't eaten as a flag); the two osascript plans interpolate into script source through `appleScriptQuote` (backslashes, then double quotes, then `\n`/`\r`/`\t` — the control characters an AppleScript literal cannot carry raw), the single escaping point. The kitty plan needs a remote-control socket (`listen_on` → `KITTY_LISTEN_ON`): the adapter runs detached, so tty-transport remote control cannot work — without the socket it degrades to the auto-fallback. Env-only — no subprocesses — so `Detect` is safe inside `Update()`. Bottom of the import graph like `updater`: no TUI knowledge | | `internal/loader` | Persist tracker metadata (`meta.yaml`: name, status, tag, note, github ref, optional `update_cmd` override); own the tool-status lifecycle (`active → trying → inactive` via `NextStatus`; legacy `forgotten`/`archived` values are migrated to `inactive` in `LoadMeta` — in-memory, the file keeps the old value until the next `SaveMeta`); hold the **one tag per tool** invariant (`Tags` stays a `[]string` so the yaml schema is unchanged, but `LoadMeta` truncates a legacy multi-tag list to `Tags[:1]` in the same migration loop — first tag wins, matching what the editor's `parseTag` does to comma-separated input. Unlike the status migration, which swaps a retired value for its successor, this one **discards user-authored data** and the next `SaveMeta` — any note edit or status cycle — makes it permanent, so a load that actually dropped tags stashes the pre-migration file as `meta.yaml.bak` first: best-effort, logged and swallowed on failure, and not rewritten by later already-migrated loads); parse GitHub refs (`NormalizeRepo`, `ParseToolRef` in `github.go`) | -| `internal/logx` | Errors-only session logger (imports only the stdlib-only `configdir` leaf); one lazily-created plain-text file per session under `/keeptui/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors | +| `internal/logx` | Errors-only session logger (imports only the stdlib-only `configdir` leaf); one lazily-created plain-text file per session under `/keepkit/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors | | `internal/model` | Entire Bubble Tea model — all TUI state, key handling, and rendering | | `internal/proc` | `DetachTTY` — runs tool probe subprocesses without a controlling terminal (`Setsid` on unix, `DETACHED_PROCESS` on Windows); `KillGroup` — process-group SIGKILL (negative pid; plain `Process.Kill` on Windows) for the update streamer's timeout path | | `internal/ui` | 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`); keeptui's own self-check (`selfcheck.go`: `SelfRepo`, `SelfLatest`) | +| `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 **or an exhausted chain**, 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`); keepkit's own self-check (`selfcheck.go`: `SelfRepo`, `SelfLatest`) | The `model` package is split by responsibility (one package, several files): @@ -53,7 +53,7 @@ The `model` package is split by responsibility (one package, several files): ### Data flow -1. `loader.LoadMeta()` reads the tool tracker from `~/.config/keeptui/meta.yaml` — the single source of tracked tools (there are no per-tool config files). +1. `loader.LoadMeta()` reads the tool tracker from `~/.config/keepkit/meta.yaml` — the single source of tracked tools (there are no per-tool config files). 2. `model.New(meta)` builds the model from the tracker metadata (`loader.ToolsFromMeta`). 3. On `Init()`, the model fires goroutines to fetch installed/latest versions and repo cards asynchronously; results arrive as messages and update the UI. @@ -66,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). 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. +`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` (keepkit'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". @@ -92,26 +92,26 @@ 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 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)`. +- **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. **An exhausted chain takes the same fallback**: a binary can be found and still belong to no known manager — a Homebrew cask app keeps its launcher on `PATH` while the executable lives inside the `.app` bundle (`agterm`), matching neither the Cellar regex nor any later step — so `Detect` retries `brewNamePlan` on `ErrUnknownManager` from `detectFromPath` too (`TestDetectBrewByNameChainExhausted`). Both sites are self-validating (they fire only when such a keg actually exists) and share `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 keepkit'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 `keepkit` 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 keepkit'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. 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`. +- **Self-update (`U`/`X`)**: keepkit watches its own releases and installs one through the very same pipeline as `[u]`. **The feature's main case is a keepkit that is not tracked**, so nothing in this path may read `meta.yaml`, the selection, or a card — every guard 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 `TestKeepkitUpdateSelfHandlingGatedOnBuild`. - **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. + - **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 keepkit's own update?": the name decides which *kind* of update it is (an update of keepkit 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 `keepkit` row switch the whole feature on where it is documented as off (`TestSelfUpdatingPredicate`, `TestKeepkitUpdateSelfHandlingGatedOnBuild`). `[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 — `keepkit available — [U] update` (the tag in `ui.UpdateAvailableStyle`) and `[X] dismiss`; `selfUpdated` renders `keepkit 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 — `keepkit ↑ [U]` (`selfDismissed`), `keepkit [U] restart` (`selfUpdatedLater`), `keepkit updating…` (`selfUpdating()`, the only in-flight indicator besides the live log, since an untracked keepkit 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. + - **Detect → confirm**: `selfTool()` returns the tracked `keepkit` 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 keepkit — 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 `keepkit` 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 keepkit'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 keepkit 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 keepkit 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 keepkit is a self-update whichever key started it, so on a release build `[u]` on a tracked `keepkit` 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 — `TestKeepkitUpdateSelfHandlingGatedOnBuild`'s release row), while on a dev build that same keypress falls through to the plain tool path below (`TestKeepkitUpdateSelfHandlingGatedOnBuild`) — the name alone as the discriminator was what let a build with the feature off announce `keepkit updated — [U] restart` and then re-exec its own working copy. Success → `selfState = selfUpdated` + `statusMsg "updated keepkit"`, 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 `keepkit 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` (`keepkit updated — run keepkit 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 keepkit 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 `./keepkit`. 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 keepkit'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 (`./keepkit`) while another keepkit 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`, keepkit's own via `dismissSelfLog()` — the latter ahead of the `selectedMeta` guard), calls `setHelpContent()` + `GotoTop()` and returns the fetch command for the mode's missing source (README via `needsReadme`, `--help`/`man` via the `helpCache` miss + `helpLoadingFor`); focus stays with the caller, because `[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). +- **Run (`enter` in `focusTools`)**: launches the selected tool without leaving keepkit. `enter` fires only in `modeNormal`+`focusTools` (empty list → no-op; in `modeSearch` enter stays the commit key) and opens `modeRunInput` — a one-line prompt (`m.runInput`, its own textinput like `m.search`, not shared with note/tags) prefilled with `m.lastRun[name]` else the tool name, cursor at end; the status bar echoes `run : [enter] run [esc] cancel`. `m.lastRun map[string]string` is session-only per-tool memory of the last dispatched command — rename's stale-state cleanup deletes the old-name entry alongside `helpCache` et al.; untrack deliberately leaves it (harmless, session-scoped). Enter with empty/whitespace input cancels like `esc`. On dispatch `launcher.Detect(command, name)` picks the path — env-only, so unlike every probe it is safe inside `Update()`: a tab plan runs its `Argv` via `startLaunchCmd` (`exec.Command` + `proc.DetachTTY`, `launchTimeout` — a 10s **var**, shrunk by the timeout/KillGroup test — with `proc.KillGroup` on expiry, `safeCmd`-wrapped) → `launchDoneMsg{toolName, command, err}`, with `m.launchingFor` (the launch twin of `updatingFor`) as the one-adapter-launch-at-a-time guard and a `launching in …` statusMsg as in-flight feedback (this is also `Plan.Terminal`'s consumer); a `Fallback` plan runs `execToolCmd` → `tea.ExecProcess` over `shellCommand(runtime.GOOS, cmd)` (`sh -c` / `cmd /c`; goos-parameterized and spawn-free like `browserCommand`, so both branches are table-testable) — keepkit suspends, Bubble Tea restores the terminal when the tool exits → `execDoneMsg{toolName, err}`. **Auto-fallback**: an adapter failure (kitty remote control off, Automation permission denied) must not strand the launch — the `launchDoneMsg` error handler sets `statusMsg` (`tab open failed — running here`) and returns `execToolCmd(msg.toolName, msg.command)`; `command` rides the msg so the handler never re-reads input state. The auto-fallback is **gated on `modeNormal`**: the result can arrive up to `launchTimeout` after enter (osascript blocked on the macOS Automation dialog), and `tea.ExecProcess` seizing the terminal under an open editor/overlay would route keystrokes to the spawned shell — under any other mode the fallback is **deferred**, not dropped: the gate stores `m.pendingLaunchName`/`Command` and `flushPendingLaunch` (mode.go) dispatches `execToolCmd` with the same statusMsg (single definition: `launchFallbackStatus`, shared with the ungated auto-fallback) on the keystroke that returns the mode to `modeNormal` (every modal return in `Update` funnels through it — the mode-dispatch switch plus the inline `modeSearch`/`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 keepkit's. The timeout carries one accepted race: an adapter killed after its tab command already executed (osascript stuck post-`write text`) still triggers the fallback, so the command can run twice — narrow, undetectable, and better than stranding genuine failures. This path also serves native Windows: `planFor` is env-only, so WezTerm there yields a doomed `sh -c` plan whose failure lands in the fallback (one noisy attempt accepted; a `GOOS` guard in `planFor` is deliberate YAGNI). Success wording is mode-neutral — `launched ` — because Terminal.app and tmux open a *window*, not a tab. **No `logx` anywhere in the flow**: a non-zero tool exit (`statusMsg " exited: "`) is the tool's business, not a keepkit anomaly, and an adapter error is a degraded path, not a malfunction (the auto-fallback still launches the tool). Launch during a running update is deliberately not blocked — independent concerns; ExecProcess pauses rendering of the live update log and the buffer catches up on resume. A not-installed tool launches anyway (no PATH pre-check): in a tab `sh` reports `command not found` inside that tab, while on the ExecProcess path the shell's not-found exit (`notFoundExit`: 127 sh / 9009 cmd.exe) maps to `statusMsg " not found — is it installed?"` instead of the cryptic raw exit status. The `[?]` overlay's tools group carries `enter — run in tab` (desc kept short — the overlay sits at the 76-col edge of its budget). - **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. @@ -120,15 +120,15 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu ### File storage -The base dir is resolved by `configdir.Base()`: `~/.config/keeptui` on **macOS and Linux** (`$XDG_CONFIG_HOME` else `~/.config` — same path on both, deliberately not macOS's `~/Library/Application Support`), `%AppData%\keeptui` on **Windows**. Paths below use the macOS/Linux form. +The base dir is resolved by `configdir.Base()`: `~/.config/keepkit` on **macOS and Linux** (`$XDG_CONFIG_HOME` else `~/.config` — same path on both, deliberately not macOS's `~/Library/Application Support`), `%AppData%\keepkit` on **Windows**. Paths below use the macOS/Linux form. | Data | Location | |---|---| -| Tracker metadata | `~/.config/keeptui/meta.yaml` | -| 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` | +| Tracker metadata | `~/.config/keepkit/meta.yaml` | +| Version, README and self-check cache (24h TTL each, separate timestamps) | `~/.config/keepkit/cache.json` | +| GitHub token (`0600`) | `~/.config/keepkit/token` | +| Session error log (lazy, per session) | `~/.config/keepkit/logs/keepkit-.log` | +| Pre-migration tracker copy (only when a load dropped tags) | `~/.config/keepkit/meta.yaml.bak` | `SaveMeta` writes atomically (temp file + `os.Rename` in the same directory) so a crash mid-write can never truncate `meta.yaml`. @@ -146,7 +146,7 @@ The internal `testConfigDir`/`testCacheDir`/`testTokenDir`/`testAPIBase`/`testBr ### 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). +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 (`keepkit / 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. @@ -154,7 +154,7 @@ Logging sites are `Errorf`-only: cache read/write failures (`version.LoadCache`/ ### 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. 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`). +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. keepkit's own self-check adds **one release-only request per 24h window** on release builds (none on a dev build), and a tracked keepkit 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/keepkit/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. @@ -174,8 +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. -**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. +**The self-check's freshness is a third timestamp.** `SelfLatest()` (`selfcheck.go`, with the exported `SelfRepo = "stanlyzoolo/keepkit"`) 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). +**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* keepkit'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 7c28d2d..207d754 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,29 @@ -# keeptui +

+ keepkit +

-A terminal TUI tracker for CLI tools: a list of tracked tools, a card with repository -data, versions and notes, the rendered repository README plus built-in `--help` / `man` -viewing, and updating outdated tools or launching any tracked tool right from the -interface. Pure TUI — no -subcommands; the only flags are `--version` and `--help`. +

+ CI + Latest release + Go Report Card + Go version + License +

-![keeptui — three-panel overview: tracker list, tool card, docs viewer (README / --help / man), live search and the hotkeys overlay](demo/hero.gif) +**keepkit** is a lightweight TUI for tracking versions of your favorite tools. +It keeps your kit in one list: installed and latest versions side by side, repository +cards, notes and tags — and updates an outdated tool right from the interface. +Pure TUI, no subcommands; the only flags are `--version` and `--help`. + +![keepkit — track a repo by URL, tag it, group the list by tag, run the tool right from the tracker, then refresh the card while the GitHub API gauge counts requests](demo/hero.gif) ## Contents -- [Features](#features) +- [Key features](#key-features) - [Installation](#installation) - [Usage](#usage) - [Updating tools](#updating-tools) -- [Updating keeptui itself](#updating-keeptui-itself) +- [Updating keepkit itself](#updating-keepkit-itself) - [GitHub API and token](#github-api-and-token) - [Data storage](#data-storage) - [Architecture](#architecture) @@ -22,162 +31,111 @@ subcommands; the only flags are `--version` and `--help`. - [Contributing](#contributing) - [License](#license) -## Features - -- **Three panels**: tools (the tracker list), brief (the tool card), docs (README / `--help` / `man`) -- **README first** — panel `[3]` opens on the repository README, rendered right in the terminal; `h` / `m` / `r` switch between `--help`, `man` and the README. A tool that is tracked but not installed still has a full panel — exactly the case where docs matter most -- **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 -- **Tracker** — add by GitHub URL, statuses, one tag per tool and notes, all inside the TUI; `space` regroups the list under tag divider headers -- **GitHub API gauge** — an API quota usage indicator in the status bar, token management via `L` -- **Mouse** — scrolling, clicking on panels, and clicking the repository / release links on the card +## Key features + +- **Track your tools** — add by GitHub URL or short name, cycle statuses + (`active` / `trying` / `inactive`), keep a note and one tag per tool; rename a tool + when the binary name differs from the repo name (e.g. `claude-code` → `claude`) +- **Versions at a glance** — the installed version is detected locally (with Homebrew + and cargo fallbacks for tools that won't answer `--version`), the latest release + comes from GitHub; outdated tools are marked `↑` and gathered at the top of the list +- **Update from inside the TUI** — one key detects the package manager + (brew / go / cargo / pipx / npm, or `update_cmd` from `meta.yaml`), asks for + confirmation and streams the command output into panel `[3]` in real time +- **Self-update** — when a newer keepkit release exists, the status bar offers it; + after the update one more key restarts keepkit in place, in the same terminal tab +- **Docs panel** — panel `[3]` switches between the rendered repository README, + `--help` output and the `man` page by hotkeys; in `--help` / `man` mode `j` / `k` + walk flags and subcommands with the current entry spotlighted +- **Clickable card** — the repository and release links on the tool card open in the + browser by mouse click, or by hotkeys for the repo and changelog pages +- **Tags and grouping** — one tag per tool; `space` regroups the flat list under tag + divider headers and back +- **Run tools** — launch any tracked tool in a new terminal tab (tmux / iTerm2 / + kitty / WezTerm / Terminal.app) or in the current window, without leaving keepkit +- **Search** — `/` filters by name and tag with match highlighting and an `N/M` counter +- **GitHub API token** — an on-screen quota gauge plus token management in the `L` + overlay lifts the anonymous 60 requests/hour to 5000 +- **Session error log** — errors (and only errors) are journaled to a per-session + file, so a misbehaving session can be researched after the fact; no errors — no file +- **Mouse support** — scrolling, panel focus, selection and card links all respond + to the mouse + +Every panel shows its own keys in the help bar at the bottom, and `?` opens the full +hotkeys overlay — every keybinding, grouped by panel. That is all you need to learn. ## Installation -Homebrew (macOS / Linux): +### Homebrew (macOS / Linux) ```bash -brew install stanlyzoolo/apps/keeptui +brew install stanlyzoolo/apps/keepkit ``` Or tap once and install by name: ```bash brew tap stanlyzoolo/apps -brew install keeptui +brew install keepkit +``` + +Upgrade later with `brew upgrade keepkit`. + +### go install + +Requires Go 1.25+: + +```bash +go install github.com/stanlyzoolo/keepkit@latest ``` -Upgrade later with `brew upgrade keeptui`. +The binary lands in `~/go/bin/keepkit` (make sure `~/go/bin` is on your `PATH`). + +### Prebuilt binaries -From source (requires Go 1.25+): +Archives for macOS, Linux and Windows (amd64 / arm64) are attached to every +[GitHub release](https://github.com/stanlyzoolo/keepkit/releases/latest). Unpack and +put `keepkit` on your `PATH`. + +### From source ```bash -git clone https://github.com/stanlyzoolo/keeptui -cd keeptui +git clone https://github.com/stanlyzoolo/keepkit +cd keepkit go install . ``` -The binary lands in `~/go/bin/keeptui`. Make sure `~/go/bin` is on your `PATH`. +Note: a build from a working copy is a *dev* build — the self-update check is off +by design. ## Usage -Run `keeptui` — the three-panel interface opens. Focus moves with `←` / `→` or the -digits `1` / `2` / `3` (each panel's number is written in its title). Press `?` at any -time for the hotkeys overlay — every keybinding, grouped by panel. - -### Panel `[1] Tools` - -| Key | Action | -|-----|--------| -| `j / k`, `↑ / ↓` | navigate the list (wraps around the edges) | -| `PgUp / PgDn`, `ctrl+f / ctrl+b` | page the selection up / down | -| `ctrl+d / ctrl+u` | move the selection half a page down / up | -| `g / G` | jump to the first / last tool | -| `space` | group the list by tag on / off — tools are gathered under tag divider headers with the label centered (`────… tag ────…`, untagged ones last); the selected tool stays selected across the toggle. With nothing tagged yet there is nothing to group, and the status bar says so | -| `t` | track — add a tool by GitHub URL or short name | -| `u` | untrack — remove (with confirmation) | -| `r` | rename — fix the binary name when it differs from the repo name (e.g. `claude-code` → `claude`) | -| `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) | +Run `keepkit` — a three-panel interface opens: + +- **`[1] Tools`** — the tracker list: search, tag grouping, track / untrack / rename, + and `enter` to run the selected tool. +- **`[2] Brief`** — the tool card: repository, stars, languages, installed and latest + versions with the release date, status, note and tag. From here tools are updated, + the repo or changelog opened in the browser, and the card data force-refreshed. +- **`[3] Readme / Help / Man`** — the docs panel: the rendered repository README (the + default), the tool's `--help` output or its `man` page. While an update runs, this + panel shows its live log instead. + +Focus moves with `←` / `→` or the digits `1` / `2` / `3` (each panel's number is in +its title). Each panel lists its keys in the help bar; press `?` any time for the +full hotkeys overlay, grouped by panel. When you enter a GitHub URL (`https://github.com/owner/repo`, with `.git`, without a -scheme, or in SSH form `git@github.com:owner/repo.git`), `keeptui` puts the short tool +scheme, or in SSH form `git@github.com:owner/repo.git`), keepkit puts the short tool name into `name` and the normalized `github.com/owner/repo` into the `github` field. A new tool gets the `trying` status. -The selected row carries the `⏺` marker, which stays visible (dimmed) while another -panel is focused. Tools with an available update are marked `↑` and gathered at the -top of the list; the order in `meta.yaml` is never changed. - -`space` switches the list to the tag view: tools are gathered under muted tag divider -headers with the label centered (`────… tag ────…`, untagged ones under `────… untagged ────…`, last), in the order the tags first appear in -`meta.yaml`; tags differing only in letter case are one group, exactly as the search -treats them. In this view tools are grouped by tag rather than by pending update — the -`↑` marker still shows per row. Header rows are not selectable: `j` / `k` step over -them and clicking one does nothing. `space` again returns to the flat view; the -selected tool stays selected either way. Both views are display-only. - -### Panel `[2] Brief` - -| Key | Action | -|-----|--------| -| `o` | open the repository in the browser | -| `c` | open the changelog / releases page in the browser | -| `u` | update the tool (available when marked `↑`); `enter` runs the shown command, `esc` cancels | -| `r` | force-refresh the tool's data (card, changelog, README, installed version), bypassing the cache | -| `s` | cycle the status (`active → trying → inactive → active`) | -| `e` | edit the note | -| `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. -Legacy `forgotten` / `archived` values from `meta.yaml` are automatically read as -`inactive`; a legacy list of several tags is read as its first tag. - -The `installed:` line has four states: the detected version, `detecting…` while the -local probe runs, `✓ no version` for a tool that is installed but won't report one -(a TUI app that ignores `--version`), and `✕ not installed`. The version is read from -`--version` / `-V`, and failing that from Homebrew's directory layout or -`cargo install --list` — so tools with no version CLI still resolve. - -The card's links are clickable: a click on the `repo:` line opens the repository in the -browser, a click on the release URL under `[changelog]` opens that release page — the -same thing `o` and `c` do from the keyboard. - -### Panel `[3] Readme / Help / Man` - -The panel has three sources; the current one is shown in its title. On startup it -opens on the **README**: the repository README is fetched from the GitHub API and -rendered in the terminal (headings, lists, code blocks, tables). The mode is global, -not per tool — pick `--help` once and moving through the list keeps showing `--help`. - -| Key | Action | -|-----|--------| -| `r` | README mode — the rendered repository README (the default); works only while `[3]` is focused, in `[1]` `r` is rename and in `[2]` refresh | -| `h` / `m` | `--help` / `man` mode (these two also work from `[2]`) | -| `j / k` | navigate by entries — flags and subcommands; the current entry is highlighted, the rest is dimmed (when there are no entries — in README mode, for example — `j / k` scroll 3 lines like the arrows) | -| `↑ / ↓` | 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 | - -The README is loaded lazily — one request per tool, cached for 24 hours — and only -for the tool whose README you actually look at: while you stay in `--help` or `man` -mode nothing is fetched at all. In README mode, though, moving to a tool for the first -time does spend that one request, so walking a long list on a cold cache costs one -request per tool visited. A tool without a `github` field, a repository without a -README, an exhausted quota or a failed fetch show a message with the way out -(`No repo for `, `No README in `, `rate limited — press [L]`, -`No README for `); `r` in the brief panel re-fetches, bypassing the cache, and -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. 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 -![in-TUI update — detect the manager, confirm the command, stream the log into panel [3]](demo/update.gif) +![in-TUI update — the card shows installed vs latest, [u] detects the manager, the log streams into panel [3], refresh confirms the new version](demo/update.gif) When the installed version lags behind the latest release (the `↑` marker), press `u` -in the brief panel. `keeptui` detects the package manager the binary was installed with: +on the tool card. keepkit detects the package manager the binary was installed with: - `brew` — a `/Cellar//…` path → `brew upgrade `; - `go` — buildinfo (`go version -m`) with a `path` field → `go install @latest`; @@ -185,22 +143,22 @@ in the brief panel. `keeptui` detects the package manager the binary was install - `pipx` — a venv in `~/.local/pipx/venvs//` → `pipx upgrade `; - `npm` — a global `node_modules/` → `npm install -g `. -If the tool has no binary of its own on `PATH`, one more check runs before giving up: -a Homebrew keg or cask named after the tool → `brew upgrade `. That covers -formulae whose binaries are named differently — `rust` installs `rustc` and `cargo`, -so there is no `rust` binary to detect from. +If the binary cannot be attributed to any of those — or the tool has no binary of +its own on `PATH` at all — one more check runs before giving up: a Homebrew keg or +cask named after the tool → `brew upgrade `. That covers formulae whose +binaries are named differently (`rust` installs `rustc` and `cargo`, so there is no +`rust` binary to detect from) and cask apps whose executable lives inside the `.app` +bundle, where the path itself names no manager. -The command is shown in the status bar for confirmation (`enter` runs it, any other -key cancels); its output streams into panel `[3] Update` in real time and the TUI -stays responsive. After a successful update the version is re-detected, the `↑` -marker disappears, and the tool leaves the update group. One update runs at a time; -a command gets 10 minutes (a sudo password prompt inside it fails fast instead of -hanging). +The command is shown in the status bar for confirmation; its output streams into +panel `[3] Update` in real time and the TUI stays responsive. After a successful +update the version is re-detected and the `↑` marker disappears. One update runs at +a time; a command gets 10 minutes (a sudo password prompt inside it fails fast +instead of hanging). -If the manager cannot be detected (manual install), `keeptui` suggests setting the -`update_cmd` field or updating manually (`o` opens the releases page). `update_cmd` -in `meta.yaml` always takes precedence over auto-detection and runs via `sh -c` -(pipes and `&&` are fine): +If the manager cannot be detected (manual install), keepkit suggests setting the +`update_cmd` field or updating manually. `update_cmd` in `meta.yaml` always takes +precedence over auto-detection and runs via `sh -c` (pipes and `&&` are fine): ```yaml - name: mytool @@ -208,116 +166,88 @@ in `meta.yaml` always takes precedence over auto-detection and runs via `sh -c` update_cmd: mytool self-update ``` -## Updating keeptui itself +## Updating keepkit 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: +keepkit 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. When a newer version exists, the status bar shows a notice: ``` -keeptui v0.5.0 available — [U] update [X] dismiss +keepkit 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` updates through the same pipeline as any tool — manager detection, confirmation, +live log in panel `[3]` (visible whichever tool is selected, even with an empty +tracker). `X` folds the notice into a compact cell next to the API gauge where `U` +keeps working; nothing is written to disk, so a dismissed notice returns on the next +launch. After a successful update the bar reads `keepkit updated — [U] restart`: `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. +pane, same arguments. On Windows there is no in-place restart: keepkit prints +`keepkit updated — run keepkit again` and exits. + +keepkit does not need to be in your own tracker for any of this — the check is built +in. If it *is* tracked, its `update_cmd` governs the self-update exactly as it +governs `u` on that row, and the release data is shared with its card. Good to know: + +- One update at a time: while any update runs, both `U` and `u` answer + `another update is running` instead of starting a second one. - 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. + 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. +- If the update fails, the bar says `update failed — see [3]` and the reason stays in + panel `[3]`. +- The update targets the `keepkit` on your `PATH`. If you launched a copy by path + (`./keepkit`) while a different one is installed, the 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 +keepkit 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. 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`). 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: - -- `e` — enter a token (echo hidden); the token is validated with a `/rate_limit` request and saved only on success; -- `d` — remove the saved token (available only for the file-based token); -- `r` — refresh the numbers; `esc` / `q` — close. +its README in panel `[3]`; a cold start with a large list and no token can hit the +limit — cards stay empty until the window resets. The keepkit 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 `L` key opens the API status overlay: token source, +quota usage with a warning icon and the reset time; right in the overlay a token can +be entered (validated before it is saved), removed or the numbers refreshed. The token source follows environment precedence: the `GITHUB_TOKEN` variable always -wins over the file. A token entered in the TUI is stored in `~/.config/keeptui/token` +wins over the file. A token entered in the TUI is stored in `~/.config/keepkit/token` with `0600` permissions; an environment token is never written to disk. When the quota is exhausted, already-loaded cards are not erased, and a card with no data shows the `rate limited — press [L]` hint. ## Data storage -The tool list lives in `~/.config/keeptui/meta.yaml` — one entry per tool (`name`, -`status`, `added`, optionally `tags`, `note`, `github`, `update_cmd`). `tags` stays a -list in the file format, but a tool has **one** tag: a longer list is read as its first -entry and rewritten as a single-item list on the next save — the file as it was before -that migration is kept as `meta.yaml.bak`, so the dropped tags stay recoverable. The file is +The tool list lives in `~/.config/keepkit/meta.yaml` — one entry per tool (`name`, +`status`, `added`, optionally `tags`, `note`, `github`, `update_cmd`). The file is fully managed from the TUI; editing it by hand is not required but safe — writes are -atomic. +atomic. `tags` stays a list in the file format, but a tool has **one** tag: a longer +legacy list is read as its first entry, and the file as it was before that migration +is kept as `meta.yaml.bak`. | What | Where | |------|-------| -| Tracker metadata | `~/.config/keeptui/meta.yaml` | -| 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` | +| Tracker metadata | `~/.config/keepkit/meta.yaml` | +| Version, README and self-check cache (24h TTL each) | `~/.config/keepkit/cache.json` | +| GitHub token (`0600`) | `~/.config/keepkit/token` | +| Session error log | `~/.config/keepkit/logs/keepkit-.log` | +| Copy of the tracker before the one-tag migration | `~/.config/keepkit/meta.yaml.bak` | -The paths above are the macOS and Linux locations (`$XDG_CONFIG_HOME` if set, otherwise -`~/.config`). On Windows the base directory is `%AppData%\keeptui\` instead. +The paths above are the macOS and Linux locations (`$XDG_CONFIG_HOME` if set, +otherwise `~/.config`). On Windows the base directory is `%AppData%\keepkit\` +instead. A config directory left by an earlier version under the previous name +(`keeptui`) is picked up and renamed automatically on first launch. -The log is created lazily — only on the first error. A session with no errors leaves -no file at all, so the presence of a file is itself the signal. The 20 most recent -logs are kept. +The error log is created lazily — only on the first error. A session with no errors +leaves no file at all, so the presence of a file is itself the signal. The 20 most +recent logs are kept. ## Architecture @@ -343,4 +273,4 @@ Bug reports and pull requests are welcome. Before submitting, run ## License -MIT +[MIT](LICENSE) diff --git a/assets/keepkit-watchlist-transparent.svg b/assets/keepkit-watchlist-transparent.svg new file mode 100644 index 0000000..20e6d2b --- /dev/null +++ b/assets/keepkit-watchlist-transparent.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/keepkit-watchlist.svg b/assets/keepkit-watchlist.svg new file mode 100644 index 0000000..45da35c --- /dev/null +++ b/assets/keepkit-watchlist.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/keepkit-wordmark.svg b/assets/keepkit-wordmark.svg new file mode 100644 index 0000000..d405696 --- /dev/null +++ b/assets/keepkit-wordmark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + keepkit + + diff --git a/demo/hero.gif b/demo/hero.gif index 9ccecd4..66c8307 100644 Binary files a/demo/hero.gif and b/demo/hero.gif differ diff --git a/demo/hero.tape b/demo/hero.tape index 6ecf8cc..5476d3a 100644 --- a/demo/hero.tape +++ b/demo/hero.tape @@ -1,56 +1,61 @@ -# keeptui hero demo — overview: list, card, README + help nav, search, hotkeys overlay. +# keepkit hero demo — track a repo, tag it, group by tag, run the tool from the +# list, then refresh the card while the GitHub API gauge counts the requests. # Render: vhs demo/hero.tape (outputs demo/hero.gif) Output demo/hero.gif -Require keeptui +Require keepkit Set Shell "zsh" -Set FontSize 16 -Set Width 1200 -Set Height 720 +Set FontSize 18 +Set Width 1880 +Set Height 960 Set Padding 16 -Set TypingSpeed 55ms +Set Framerate 30 +Set TypingSpeed 35ms -# clean, neutral prompt for the intro line +# clean, neutral prompt; force the in-window launch path regardless of the +# terminal vhs was started from Hide +Type "unset TMUX TERM_PROGRAM KITTY_LISTEN_ON" Enter Type "PS1='$ '" Enter Type "clear" Enter Show -Sleep 800ms - -Type "keeptui" Sleep 400ms Enter -Sleep 4s - -# browse the tracker list -Type "j" Sleep 850ms -Type "j" Sleep 850ms -Type "j" Sleep 850ms -Type "j" Sleep 1100ms -Type "k" Sleep 1100ms - -# focus the brief card [2] -Type "2" Sleep 2600ms - -# focus the docs panel [3]: it opens on the rendered README, scroll it a bit -Type "3" Sleep 2000ms -Type "j" Sleep 700ms -Type "j" Sleep 1400ms - -# switch to --help with [h] and walk entries with j/k (the entry spotlight) -Type "h" Sleep 1500ms -Type "j" Sleep 700ms -Type "j" Sleep 700ms -Type "j" Sleep 1400ms - -# back to tools [1], live search narrows the list by name -Type "1" Sleep 800ms -Type "/" Sleep 700ms -Type "g" Sleep 900ms -Type "l" Sleep 2400ms -Escape Sleep 1200ms - -# hotkeys overlay [?] -Type "?" Sleep 3500ms -Escape Sleep 800ms +Sleep 600ms + +Type "keepkit" Sleep 300ms Enter +Sleep 3s + +# track a new tool by GitHub URL +Type "t" Sleep 300ms +Type "https://github.com/Epistates/treemd" Sleep 300ms +Enter Sleep 2.5s + +# tag it: [2] focuses the card, [t] edits the tag +Type "2" Sleep 300ms +Type "t" Sleep 300ms +Type "viewers" Sleep 300ms +Enter Sleep 1s + +# back to the list, [space] groups by tag — treemd sits under its new header +Type "1" Sleep 300ms +Space Sleep 2s +Type "k" Sleep 300ms +Type "j" Sleep 1s + +# run it right from keepkit: the prompt is prefilled with the tool name +Enter Sleep 300ms +Type " README.md" Sleep 300ms +Enter Sleep 3.5s + +# a couple of moves inside treemd, then quit — keepkit resumes +Type "j" Sleep 300ms +Type "j" Sleep 1.2s +Type "q" Sleep 1.5s + +# refresh the card a few times — the GitHub API gauge in the corner counts up +Type "2" Sleep 300ms +Type "r" Sleep 1.5s +Type "r" Sleep 1.5s +Type "r" Sleep 3s Type "q" -Sleep 1200ms +Sleep 1s diff --git a/demo/update.gif b/demo/update.gif index 2f6b028..5df900f 100644 Binary files a/demo/update.gif and b/demo/update.gif differ diff --git a/demo/update.tape b/demo/update.tape index c1819cf..3a50983 100644 --- a/demo/update.tape +++ b/demo/update.tape @@ -1,36 +1,48 @@ -# keeptui update demo — in-TUI update of an outdated tool (ttl), streaming log. +# keepkit update demo — agterm has a newer release: the card shows installed vs +# latest, [u] detects the manager, the log streams into [3], refresh confirms. # Render: vhs demo/update.tape (outputs demo/update.gif) -# Note: this actually runs `brew upgrade ttl`. +# Note: this actually runs `brew upgrade agterm`. Output demo/update.gif -Require keeptui +Require keepkit Set Shell "zsh" -Set FontSize 16 -Set Width 1200 -Set Height 720 +Set FontSize 18 +Set Width 1880 +Set Height 960 Set Padding 16 -Set TypingSpeed 55ms +Set Framerate 30 +Set TypingSpeed 35ms Hide +Type "unset TMUX TERM_PROGRAM KITTY_LISTEN_ON" Enter +Type "export HOMEBREW_NO_AUTO_UPDATE=1" Enter Type "PS1='$ '" Enter Type "clear" Enter Show -Sleep 800ms +Sleep 600ms -Type "keeptui" Sleep 400ms Enter -Sleep 6s +Type "keepkit" Sleep 300ms Enter +Sleep 3s -# ttl is grouped at the top with the ↑ marker; jump to it and open its card [2] -Type "g" Sleep 1500ms -Type "2" Sleep 2800ms +# find agterm: committing the search lands straight on its card +Type "/" Sleep 300ms +Type "agterm" Sleep 300ms +Enter Sleep 3s -# [u] detects the package manager and shows the command for confirmation -Type "u" Sleep 3000ms +# the card: installed 0.15.1, latest v0.16.1 with the ↑ marker +Type "u" Sleep 2.5s -# run it — output streams into panel [3] Update in real time -Enter Sleep 24s +# [u] detected the manager — run the shown command; the log streams into [3] +Enter Sleep 5s -# card title reverts, ↑ is gone; leave it on screen a moment +# skip the long middle of the download, come back for the finished log +Hide +Sleep 150s +Show Sleep 3s + +# refresh the card: installed == latest now, the ↑ is gone +Type "r" Sleep 3s + Type "q" -Sleep 1200ms +Sleep 1s diff --git a/docs/plans/completed/20260717-error-log.md b/docs/plans/completed/20260717-error-log.md index 512c707..9be913e 100644 --- a/docs/plans/completed/20260717-error-log.md +++ b/docs/plans/completed/20260717-error-log.md @@ -56,7 +56,7 @@ Add a per-session error journal so bugs can be researched after the fact instead ## Solution Overview -**Lazy per-session file.** `~/.config/keys/logs/keeptui-2026-07-17_14-32-01.log`. The file is created under the mutex on the *first* write; the header is written as its first line at that moment. No errors in a session → no file, no I/O, no cleanup pressure. +**Lazy per-session file.** `~/.config/keys/logs/keepkit-2026-07-17_14-32-01.log`. The file is created under the mutex on the *first* write; the header is written as its first line at that moment. No errors in a session → no file, no I/O, no cleanup pressure. **Why per-session rather than one rotating file:** no cross-process mutex between concurrent `keys` instances, no size-based rotation, and "send me the log from that crash" becomes "send the file with that timestamp". @@ -96,7 +96,7 @@ Not JSON — a human reads this file, and `grep ERROR` already works. **Header assembly** stays in `main.go` (`logx.SetHeader`) because version/tool-count/token-source belong to `main`/`loader`/`version`, and pulling them into `logx` would invert the import graph. -**Cleanup — keep the newest 20**, called synchronously from `main.go` before `p.Run()`: `os.ReadDir` over a couple dozen entries costs fractions of a millisecond, while a goroutine would race the tests. Filter by `keeptui-` prefix + `.log` suffix, sort by name, delete the tail. Foreign files in the directory are never touched. +**Cleanup — keep the newest 20**, called synchronously from `main.go` before `p.Run()`: `os.ReadDir` over a couple dozen entries costs fractions of a millisecond, while a goroutine would race the tests. Filter by `keepkit-` prefix + `.log` suffix, sort by name, delete the tail. Foreign files in the directory are never touched. **Logging sites (agreed list, `Errorf` only):** @@ -129,7 +129,7 @@ Not JSON — a human reads this file, and `grep ERROR` already works. - [x] add `logDir()` resolving `~/.config/keys/logs` via `os.UserConfigDir()` (honoring `dirOverride`), mirroring `loader.MetaPath` (meta.go:43) - [x] add **exported** `SetDirForTesting(dir string) (restore func())`: locks, zeroes `file`/`path`/`header`/`failed`, sets `dirOverride=dir`; `restore` reverts the override and re-zeros the state (so `version`/`loader`/`model` tests can redirect logx AND stay order-independent) - [x] add `SetHeader(s string)` storing the header in memory without creating a file -- [x] add `Errorf(format string, args ...any)`: lock; if `file==nil && !failed`, lazily `MkdirAll` + create `keeptui-.log` (`2006-01-02_15-04-05`, no colons) — on open error set `failed=true` and return (never retry); on success stash `path`, write header line (only if `header != ""`); then append the record +- [x] add `Errorf(format string, args ...any)`: lock; if `file==nil && !failed`, lazily `MkdirAll` + create `keepkit-.log` (`2006-01-02_15-04-05`, no colons) — on open error set `failed=true` and return (never retry); on success stash `path`, write header line (only if `header != ""`); then append the record - [x] add `Path() string` returning `path` (or `""`) - [x] swallow all internal errors silently (no panic, no stderr) - [x] write test: `Path()` with no `Errorf` creates no file (lazy) @@ -159,11 +159,11 @@ Not JSON — a human reads this file, and `grep ERROR` already works. - Create: `internal/logx/cleanup.go` - Modify: `internal/logx/logx_test.go` -- [x] create `internal/logx/cleanup.go` with `Cleanup()`: `os.ReadDir(logDir())`, filter `keeptui-` prefix + `.log` suffix, sort by name (lexicographic = chronological), remove all but the newest 20 +- [x] create `internal/logx/cleanup.go` with `Cleanup()`: `os.ReadDir(logDir())`, filter `keepkit-` prefix + `.log` suffix, sort by name (lexicographic = chronological), remove all but the newest 20 - [x] return silently when the directory does not exist (the common case — no errors yet) - [x] swallow removal errors silently - [x] write test: 25 seeded files → 20 newest remain, 5 oldest gone -- [x] write test: a foreign `notes.txt` and a `keeptui-x.txt` are left untouched +- [x] write test: a foreign `notes.txt` and a `keepkit-x.txt` are left untouched - [x] write test: missing directory → no error, no panic - [x] write test: fewer than 20 files → nothing removed - [x] run `go test -race ./internal/logx/` - must pass before task 4 @@ -174,7 +174,7 @@ Not JSON — a human reads this file, and `grep ERROR` already works. - Modify: `main.go` - [x] add `var version = "dev"` to `main.go` — `release.yml:50` already passes `-X main.version` into a symbol that does not exist, so this fixes a silent no-op -- [x] import `internal/version` under an **alias** (`verpkg "github.com/stanlyzoolo/keeptui/internal/version"`) — the package name `version` collides with `var version` in the same file and would not compile otherwise +- [x] import `internal/version` under an **alias** (`verpkg "github.com/stanlyzoolo/keepkit/internal/version"`) — the package name `version` collides with `var version` in the same file and would not compile otherwise - [x] call `logx.Cleanup()` at startup, before `p.Run()` - [x] set a partial header (`keys /`) before `LoadMeta`, then enrich it with tool count + `verpkg.TokenSource()` after a successful load — so the earliest possible error (a `LoadMeta` failure) still gets a non-blank header - [x] log `loader.LoadMeta` failure via `logx.Errorf` before the existing `os.Exit(1)` @@ -292,7 +292,7 @@ Not JSON — a human reads this file, and `grep ERROR` already works. ### Task 13: [Final] Update documentation - [x] add `internal/logx` row to the package table in CLAUDE.md -- [x] add `~/.config/keys/logs/keeptui-.log` row to the File storage table in CLAUDE.md +- [x] add `~/.config/keys/logs/keepkit-.log` row to the File storage table in CLAUDE.md - [x] add a CLAUDE.md paragraph on the policy: why our `recover` sits deeper than Bubble Tea's and re-panics, why `WithoutCatchPanics` is not used, why errors-only, why lazy creation makes file-existence a signal - [x] document the `testLogDir` seam alongside the existing `testConfigDir`/`testCacheDir` mention - [x] note `main.version` now exists and `release.yml`'s `-X` ldflag actually takes effect diff --git a/docs/plans/completed/20260721-tool-launcher.md b/docs/plans/completed/20260721-tool-launcher.md index 56c49bd..d6d979d 100644 --- a/docs/plans/completed/20260721-tool-launcher.md +++ b/docs/plans/completed/20260721-tool-launcher.md @@ -2,9 +2,9 @@ ## Overview -- `enter` on the selected tool in `[1] Tools` opens a one-line command prompt (prefilled with the tool name, or the last command run for it this session) and launches the command in a **new terminal tab named after the tool** — the keeptui session keeps running. -- Tab opening is terminal-specific, so a detection chain picks the adapter: tmux → iTerm2 → Terminal.app → kitty → wezterm. Terminals with no scripting API (agterm, ghostty, warp; Windows lands here too — via the auto-fallback, see Technical Details) fall back to `tea.ExecProcess`: the tool runs in the current window, keeptui suspends and resumes when it exits. -- Solves: launching `yazi`/`fzf`/`dive`-style tools straight from the tracker without leaving keeptui or manually opening tabs. +- `enter` on the selected tool in `[1] Tools` opens a one-line command prompt (prefilled with the tool name, or the last command run for it this session) and launches the command in a **new terminal tab named after the tool** — the keepkit session keeps running. +- Tab opening is terminal-specific, so a detection chain picks the adapter: tmux → iTerm2 → Terminal.app → kitty → wezterm. Terminals with no scripting API (agterm, ghostty, warp; Windows lands here too — via the auto-fallback, see Technical Details) fall back to `tea.ExecProcess`: the tool runs in the current window, keepkit suspends and resumes when it exits. +- Solves: launching `yazi`/`fzf`/`dive`-style tools straight from the tracker without leaving keepkit or manually opening tabs. ## Context (from discovery) @@ -43,7 +43,7 @@ - `internal/launcher` owns "how do I open a tab here": `planFor(env, command, toolName)` is a pure function over an injected `env func(string) string`, returning `Plan{Argv []string, Fallback bool, Terminal string}`. `Detect(command, toolName)` is the thin `os.Getenv` wrapper. - Detection priority (first hit wins): `$TMUX` set → tmux (deliberately first: inside tmux `TERM_PROGRAM` names the *outer* terminal, and a tmux window is the correct "tab" there) → `$TERM_PROGRAM == "iTerm.app"` → `$TERM_PROGRAM == "Apple_Terminal"` → `$KITTY_WINDOW_ID` set → `$TERM_PROGRAM == "WezTerm"` → `Plan{Fallback: true}`. - The user command always executes as `sh -c ` (`cmd /c` on Windows, fallback path only). For tmux/kitty/wezterm the command travels as an argv element — no escaping. For the two AppleScript paths it is interpolated into the script source — `appleScriptQuote` (backslashes then double quotes) is the single escaping point. -- Model side: `modeRunInput` (new input mode) → on enter, `launcher.Detect` (env-only, safe inside `Update()`); tab path runs the adapter argv as a `tea.Cmd` under `proc.DetachTTY` → `launchDoneMsg{toolName, cmd, err}`; adapter failure (kitty remote control off, Automation permission denied) **auto-falls back** to `tea.ExecProcess` so the tool always launches, with the status bar explaining. Non-zero exit of the tool itself → `statusMsg` only, no `logx` (a tool exiting non-zero is not a keeptui anomaly). +- Model side: `modeRunInput` (new input mode) → on enter, `launcher.Detect` (env-only, safe inside `Update()`); tab path runs the adapter argv as a `tea.Cmd` under `proc.DetachTTY` → `launchDoneMsg{toolName, cmd, err}`; adapter failure (kitty remote control off, Automation permission denied) **auto-falls back** to `tea.ExecProcess` so the tool always launches, with the status bar explaining. Non-zero exit of the tool itself → `statusMsg` only, no `logx` (a tool exiting non-zero is not a keepkit anomaly). ## Technical Details @@ -59,7 +59,7 @@ - Empty input on enter = cancel (no-op, back to `modeNormal`). Empty tool list: `enter` in `focusTools` is a no-op. - Launch while `updatingFor != ""` is deliberately NOT blocked — independent concerns (ExecProcess pauses rendering of the live update log; the buffer catches up on resume). - Windows note: `planFor` is env-only, so WezTerm on native Windows still yields the wezterm tab plan (whose `sh -c` will fail there) — Windows is served by the **auto-fallback** (`launchDoneMsg{err}` → `cmd /c` exec), not by detection short-circuiting. One noisy failed attempt is accepted; a `GOOS` guard in `planFor` is deliberate YAGNI until a Windows user reports it. -- A not-installed tool launches anyway — `sh` reports `command not found` in the tab; keeptui does not pre-check PATH. +- A not-installed tool launches anyway — `sh` reports `command not found` in the tab; keepkit does not pre-check PATH. ## What Goes Where @@ -142,7 +142,7 @@ ## Post-Completion **Manual verification** (adapters shell out to real terminal APIs that unit tests must not touch): -- in agterm (no adapter): `enter` on `yazi` → runs in the current window, keeptui resumes on exit with no screen corruption +- in agterm (no adapter): `enter` on `yazi` → runs in the current window, keepkit resumes on exit with no screen corruption - in iTerm2: new tab opens, named after the tool, command runs; denying the Automation permission triggers the auto-fallback path - inside tmux: `tmux new-window` named after the tool, even when the outer terminal is iTerm2 - `dive` flow: enter → append an image arg → next launch prefills the previous command diff --git a/docs/plans/completed/20260723-brief-glyph-links-tag-grouping.md b/docs/plans/completed/20260723-brief-glyph-links-tag-grouping.md index 6454378..ad71b91 100644 --- a/docs/plans/completed/20260723-brief-glyph-links-tag-grouping.md +++ b/docs/plans/completed/20260723-brief-glyph-links-tag-grouping.md @@ -2,7 +2,7 @@ ## Context -Five small-to-medium UX corrections requested for keeptui, agreed during a brainstorm: +Five small-to-medium UX corrections requested for keepkit, agreed during a brainstorm: 1. **`installed` tag glyph** — the `latest:` line already prints a Nerd Font tag glyph U+F412 (`nf-oct-tag`) before the version; `installed:` should get the same glyph before its version for visual parity. 2. **Select/copy — dropped** (YAGNI, out of scope). @@ -114,7 +114,7 @@ Run the `docs-sync` skill — these touch documented surfaces: ## Verification -1. `go build .` and run `go run .` against a real `~/.config/keeptui/meta.yaml`. +1. `go build .` and run `go run .` against a real `~/.config/keepkit/meta.yaml`. - Brief shows the U+F412 glyph before the installed version. - Click the `repo:` line → browser opens the repo; click the changelog URL line → opens that release page; clicks elsewhere do nothing. - Edit a tag with `t`: typing `cli, foo` stores the whole string as one tag (or first token — per the open-detail decision); the card shows a single tag. diff --git a/docs/plans/completed/20260724-rust-version-update-detect.md b/docs/plans/completed/20260724-rust-version-update-detect.md index d71c89a..b30a6e1 100644 --- a/docs/plans/completed/20260724-rust-version-update-detect.md +++ b/docs/plans/completed/20260724-rust-version-update-detect.md @@ -2,7 +2,7 @@ ## Overview -Чинит две проблемы с Rust-инструментами в keeptui и заодно делает строку `installed:` честной: +Чинит две проблемы с Rust-инструментами в keepkit и заодно делает строку `installed:` честной: 1. **`rust` не обновляется.** Трекается как `github.com/rust-lang/rust`, но бинарника `rust` нет — `rustc`/`cargo` стоят из Homebrew (`/opt/homebrew/Cellar/rust/1.96.1`). `updater.Detect` стартует с `exec.LookPath("rust")`, промахивается и сразу отдаёт `ErrUnknownManager` → «no known updater». Правильная команда очевидна: `brew upgrade rust`. 2. **Rust-TUI (пример `inertia`, крейт `inertia-tui`, `~/.cargo/bin/inertia`) показываются `installed: ✕ not found`,** хотя установлены и запускаются по `enter`. Это ratatui-приложение: игнорирует `--version`/`-V`, грузит свой TUI, а запущенное детачнуто (`proc.DetachTTY`) паникует с exit 101. Вывод содержит alt-screen сигнатуру `\x1b[?1049l` и строку `ratatui-0.30.2`. Итог: (а) версия не парсится → not found; (б) anomaly-лог пишется при **каждом** старте; (в) латентный мис-парс — `versionRe` матчит `0.30.2` из `ratatui-0.30.2`, сейчас маскируется ненулевым exit. Версия доступна без запуска бинарника: `cargo install --list` → `inertia-tui v0.1.0:`. @@ -150,6 +150,6 @@ - проверить, что `logs/` не пополняется на чистом старте (сигнал logx «лог = что-то сломалось» цел). **Caveat (скоуп не расширять):** -- Фикс `updater` убирает тупик «no known updater», но *предложит* ли keeptui апдейт rust (маркер `↑` + активный `[u]`) зависит от того, публикует ли `github.com/rust-lang/rust` GitHub **Releases** и есть ли тег новее установленного `1.96.1`. Если релизов нет или версия совпадает — `[u]` скажет «no update available». Это про источник данных, Вариантом A не покрывается. +- Фикс `updater` убирает тупик «no known updater», но *предложит* ли keepkit апдейт rust (маркер `↑` + активный `[u]`) зависит от того, публикует ли `github.com/rust-lang/rust` GitHub **Releases** и есть ли тег новее установленного `1.96.1`. Если релизов нет или версия совпадает — `[u]` скажет «no update available». Это про источник данных, Вариантом A не покрывается. - Присутствие определяется по `LookPath`, а не по наличию man/help (они в `model`; тянуть в `version` — инверсия графа). Кейс «man есть, бинарника в PATH нет» не покрываем (YAGNI). - Максимум один лишний `cargo install --list` для редкого «установлен, непробуем, не brew и не cargo» тула; при отсутствии cargo `LookPath` закорачивает мгновенно. diff --git a/docs/plans/completed/20260725-self-update-restart.md b/docs/plans/completed/20260725-self-update-restart.md index c63b0ca..cdde476 100644 --- a/docs/plans/completed/20260725-self-update-restart.md +++ b/docs/plans/completed/20260725-self-update-restart.md @@ -1,12 +1,12 @@ -# Self-update: "new keeptui version" banner + update with restart +# Self-update: "new keepkit version" banner + update with restart ## Overview -keeptui starts watching its own releases and offers to update without leaving the TUI: +keepkit 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`. +1. **Built-in self-check** — one lightweight request at startup (release only, not a full `GetRepoData`) to `stanlyzoolo/keepkit`, cached for 24h. Works for any user out of the box, independent of `meta.yaml` — **including an untracked keepkit, 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** — `keepkit 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 `keepkit ↑ [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 (keepkit 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) @@ -14,7 +14,7 @@ keeptui starts watching its own releases and offers to update without leaving th *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. +- **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 keepkit'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`); @@ -24,11 +24,11 @@ keeptui starts watching its own releases and offers to update without leaving th - 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. + - the card spinner (`render.go:1045`) — `updatingFor == t.Name`; an untracked keepkit 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. +- **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/keepkit` 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 keepkit 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 @@ -53,31 +53,31 @@ keeptui starts watching its own releases and offers to update without leaving th ## 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()`. +- **`version`:** exported `SelfRepo = "stanlyzoolo/keepkit"` 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 keepkit 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 = "keepkit"`; 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. +- **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** keepkit'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). + - `selfOffered` — a two-cell banner: `keepkit 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 `keepkit ↑ [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 `keepkit updating…` cell (the only in-flight indicator besides the log in `[3]` — an untracked keepkit 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. + - `selfUpdated` — the banner `keepkit updated — [U] restart` + `[X] later`; `U` → restart, `X` → `selfUpdatedLater`. + - `selfUpdatedLater` — a compact `keepkit [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). +- **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 keepkit — 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 keepkit (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. +- **`updateDoneMsg` under self:** the self branch runs **ahead of** the `toolByName` early return (`model.go:713`, handler from `:705`) — for an untracked keepkit `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 == keepkit && selfCheckEnabled()`): an update of keepkit 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 keepkit`; `fetchInstalledCmd(t)` only when keepkit 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). +- **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 = "keepkit updated — run keepkit 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). @@ -96,9 +96,9 @@ keeptui starts watching its own releases and offers to update without leaving th - 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 +- [x] `selfcheck.go`: `const SelfRepo = "stanlyzoolo/keepkit"`; `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]** `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** keepkit'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. @@ -114,10 +114,10 @@ keeptui starts watching its own releases and offers to update without leaving th - 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` +- [x] `model.go`: const `selfToolName = "keepkit"`; 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. + - **[deviation, review]** `selfUpdating()` is derived not from `updatingFor == selfToolName && selfUpdateLog` (the plan) but from `isSelfUpdate(updatingFor)` = `name == keepkit && 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 `keepkit` 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. @@ -137,13 +137,13 @@ keeptui starts watching its own releases and offers to update without leaving th - 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()` +- [x] `render.go` `renderStatusBar`: in the three normal focus branches, replace the hint cells at `selfOffered` with a two-cell banner — `keepkit available — [U] update` (info and action fused so `U` survives a right-side drop; the version in `UpdateAvailableStyle`) and `[X] dismiss`; at `selfUpdated` — `keepkit 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 +- [x] `render.go` `renderHintsBar` (`:218`): a second right-hand cell next to the gauge — `keepkit ↑ [U]` at `selfDismissed`, `keepkit [U] restart` at `selfUpdatedLater`, `keepkit 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. + - **[decision]** both `U` branches are `setStatus` stubs (`detecting how keepkit was installed…` / `restart keepkit 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) @@ -164,22 +164,22 @@ keeptui starts watching its own releases and offers to update without leaving th - [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` +- [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 keepkit — 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. + - **[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 keepkit 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 release is the helper `dismissSelfLog()`, which drops **only** `selfUpdateLog` and never `updateLogFor`: a tracked keepkit 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 keepkit would be lost silently and `[U] restart` would be unreachable; success → `selfState = selfUpdated` + statusMsg `updated keepkit` (+ `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 == keepkit && selfCheckEnabled()`). The plan (and the first implementation) held `selfUpdateLog && msg.tool == selfToolName` so that `[u]` on a tracked `keepkit` row would **stay** an ordinary tool update. Review reversed that: updating keepkit is updating keepkit 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 `TestKeepkitUpdateSelfHandlingGatedOnBuild`). 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 (`TestKeepkitUpdateSelfHandlingGatedOnBuild`). + - **[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 `keepkit 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] 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 `keepkit` with a *different* tool selected and with an empty list; done success **with keepkit 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 keepkit 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 keepkit **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` @@ -194,9 +194,9 @@ keeptui starts watching its own releases and offers to update without leaving th - [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]` +- [x] `restart.go`/`restart_unix.go`: const `restartHint = "keepkit updated — run keepkit 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]** 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("keepkit")` on unix never answers `keepkit.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. @@ -209,17 +209,17 @@ keeptui starts watching its own releases and offers to update without leaving th - **[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` + - plus: `lookPath` is not consulted for a path-shaped argv0 (a same-named binary earlier in PATH must not intercept `./keepkit`), 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 +- [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 keepkit 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. + - **[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 `TestKeepkitUpdateSelfHandlingGatedOnBuild` (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 +- [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 keepkit — 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. @@ -232,7 +232,7 @@ keeptui starts watching its own releases and offers to update without leaving th - 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 + - a new **Updating keepkit 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 @@ -243,12 +243,12 @@ keeptui starts watching its own releases and offers to update without leaving th *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. +- 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]` → `keepkit updated — [U] restart`. +- `[U]` restart: in tmux/iTerm the tab survives and the new keepkit 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/keepkit` entry in `cache.json` (when keepkit 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. +- Quitting keepkit 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/go.mod b/go.mod index 8c9f475..ca7d794 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/stanlyzoolo/keeptui +module github.com/stanlyzoolo/keepkit go 1.25.0 diff --git a/internal/configdir/configdir.go b/internal/configdir/configdir.go index 645e7e9..7988b13 100644 --- a/internal/configdir/configdir.go +++ b/internal/configdir/configdir.go @@ -1,11 +1,11 @@ -// Package configdir resolves keeptui's base user-config directory — the parent -// of the keeptui/ subdir that holds meta.yaml, cache.json, the token and logs. +// Package configdir resolves keepkit's base user-config directory — the parent +// of the keepkit/ subdir that holds meta.yaml, cache.json, the token and logs. // It is the bottom leaf of the import graph (stdlib only), so every package that // owns one of those files can share the resolution without an import cycle. // // The resolution deliberately differs from os.UserConfigDir(): on macOS that -// returns ~/Library/Application Support, but keeptui keeps macOS and Linux on -// the same ~/.config/keeptui path (honoring $XDG_CONFIG_HOME). Windows still +// returns ~/Library/Application Support, but keepkit keeps macOS and Linux on +// the same ~/.config/keepkit path (honoring $XDG_CONFIG_HOME). Windows still // follows os.UserConfigDir() (%AppData%), where there is no ~/.config convention. package configdir @@ -15,8 +15,8 @@ import ( "runtime" ) -// Base returns keeptui's base user-config directory. Callers append the -// "keeptui" subdir (and the file) themselves, matching what os.UserConfigDir() +// Base returns keepkit's base user-config directory. Callers append the +// "keepkit" subdir (and the file) themselves, matching what os.UserConfigDir() // callers did before. See baseFor for the per-GOOS rules. func Base() (string, error) { return baseFor(runtime.GOOS, os.Getenv, os.UserConfigDir, os.UserHomeDir) diff --git a/internal/loader/github.go b/internal/loader/github.go index 54e91b8..53e8c64 100644 --- a/internal/loader/github.go +++ b/internal/loader/github.go @@ -30,7 +30,7 @@ func NormalizeRepo(s string) string { return owner + "/" + repo } -// ParseToolRef classifies a `keeptui track` argument. When arg refers to a GitHub +// ParseToolRef classifies a `keepkit track` argument. When arg refers to a GitHub // repository it returns a short tool name (the repo segment) and a normalized // "github.com/owner/repo" string. Otherwise it returns the argument unchanged as // a plain name with isGitHub=false. diff --git a/internal/loader/main_test.go b/internal/loader/main_test.go index 717a233..6e1d4f2 100644 --- a/internal/loader/main_test.go +++ b/internal/loader/main_test.go @@ -4,12 +4,12 @@ import ( "os" "testing" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // TestMain redirects logx to a throwaway directory for the whole package test // binary, so tests that exercise SaveMeta's error paths never write -// keeptui-*.log into the real user config dir. Individual tests that assert +// keepkit-*.log into the real user config dir. Individual tests that assert // logger output still call logx.SetDirForTesting with their own temp dir; its // restore reverts to this fallback (not the real dir). // @@ -18,12 +18,12 @@ import ( // user's tool list. Per-test helpers (useTempConfigDir) nest inside this one and // restore back to it, never to the real directory. func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "keeptui-loader-logs") + dir, err := os.MkdirTemp("", "keepkit-loader-logs") if err != nil { panic(err) } restore := logx.SetDirForTesting(dir) - cfgDir, err := os.MkdirTemp("", "keeptui-loader-config") + cfgDir, err := os.MkdirTemp("", "keepkit-loader-config") if err != nil { panic(err) } diff --git a/internal/loader/meta.go b/internal/loader/meta.go index d97c942..babf807 100644 --- a/internal/loader/meta.go +++ b/internal/loader/meta.go @@ -7,8 +7,8 @@ import ( "gopkg.in/yaml.v3" - "github.com/stanlyzoolo/keeptui/internal/configdir" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/configdir" + "github.com/stanlyzoolo/keepkit/internal/logx" ) type Status string @@ -75,13 +75,13 @@ func ConfigDirOverride() string { func MetaPath() string { if testConfigDir != "" { - return filepath.Join(testConfigDir, "keeptui", "meta.yaml") + return filepath.Join(testConfigDir, "keepkit", "meta.yaml") } configDir, err := configdir.Base() if err != nil { return "" } - return filepath.Join(configDir, "keeptui", "meta.yaml") + return filepath.Join(configDir, "keepkit", "meta.yaml") } func LoadMeta() ([]ToolMeta, error) { diff --git a/internal/loader/meta_test.go b/internal/loader/meta_test.go index 39045b7..9ba5293 100644 --- a/internal/loader/meta_test.go +++ b/internal/loader/meta_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // useTempConfigDir points MetaPath at a per-test directory and restores the @@ -36,7 +36,7 @@ func TestLoadMetaMissingFile(t *testing.T) { func TestLoadMetaMalformedYAML(t *testing.T) { dir := useTempConfigDir(t) - path := filepath.Join(dir, "keeptui", "meta.yaml") + path := filepath.Join(dir, "keepkit", "meta.yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -96,7 +96,7 @@ func TestSaveMetaLoadMetaPreservesUpdateCmd(t *testing.T) { } // omitempty: a tool without update_cmd must not serialize the field. - onDisk, err := os.ReadFile(filepath.Join(dir, "keeptui", "meta.yaml")) + onDisk, err := os.ReadFile(filepath.Join(dir, "keepkit", "meta.yaml")) if err != nil { t.Fatal(err) } @@ -114,7 +114,7 @@ func TestSaveMetaLeavesNoTempFile(t *testing.T) { if err := SaveMeta([]ToolMeta{{Name: "a", Status: StatusActive}}); err != nil { t.Fatalf("SaveMeta: %v", err) } - entries, err := os.ReadDir(filepath.Join(dir, "keeptui")) + entries, err := os.ReadDir(filepath.Join(dir, "keepkit")) if err != nil { t.Fatal(err) } @@ -215,7 +215,7 @@ func TestLoadMetaMigratesRetiredStatuses(t *testing.T) { - name: oddball status: bogus ` - path := filepath.Join(dir, "keeptui", "meta.yaml") + path := filepath.Join(dir, "keepkit", "meta.yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -256,7 +256,7 @@ func TestLoadMetaMigratesMultiTag(t *testing.T) { tags: [cli] - name: none ` - path := filepath.Join(dir, "keeptui", "meta.yaml") + path := filepath.Join(dir, "keepkit", "meta.yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -286,7 +286,7 @@ func TestLoadMetaMigratesMultiTag(t *testing.T) { func TestLoadMetaMultiTagMigrationRoundTrip(t *testing.T) { dir := useTempConfigDir(t) - path := filepath.Join(dir, "keeptui", "meta.yaml") + path := filepath.Join(dir, "keepkit", "meta.yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -344,7 +344,7 @@ func TestLoadMetaMultiTagMigrationRoundTrip(t *testing.T) { func TestLoadMetaMigrationRoundTrip(t *testing.T) { dir := useTempConfigDir(t) - path := filepath.Join(dir, "keeptui", "meta.yaml") + path := filepath.Join(dir, "keepkit", "meta.yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } diff --git a/internal/logx/cleanup.go b/internal/logx/cleanup.go index 9918824..eb1a662 100644 --- a/internal/logx/cleanup.go +++ b/internal/logx/cleanup.go @@ -11,7 +11,7 @@ import ( const keepLogs = 20 // Cleanup removes all but the newest keepLogs session logs. It filters to the -// keeptui-*.log naming, sorts by name (lexicographic order equals chronological +// keepkit-*.log naming, sorts by name (lexicographic order equals chronological // order because the timestamp is zero-padded and colon-free), and deletes the // tail. Foreign files in the directory are never touched. A missing directory // (the common case — no errors yet) and any removal error are ignored. @@ -34,7 +34,7 @@ func Cleanup() { continue } name := e.Name() - if strings.HasPrefix(name, "keeptui-") && strings.HasSuffix(name, ".log") { + if strings.HasPrefix(name, "keepkit-") && strings.HasSuffix(name, ".log") { logs = append(logs, name) } } diff --git a/internal/logx/cleanup_test.go b/internal/logx/cleanup_test.go index e95ce65..e7e3fde 100644 --- a/internal/logx/cleanup_test.go +++ b/internal/logx/cleanup_test.go @@ -21,7 +21,7 @@ func TestCleanupKeepsNewest20(t *testing.T) { // 25 files with lexicographically ordered names (chronological). for i := 0; i < 25; i++ { - seed(t, dir, fmt.Sprintf("keeptui-2026-07-17_%02d-00-00.log", i)) + seed(t, dir, fmt.Sprintf("keepkit-2026-07-17_%02d-00-00.log", i)) } Cleanup() @@ -32,13 +32,13 @@ func TestCleanupKeepsNewest20(t *testing.T) { } // The 5 oldest (00..04) must be gone; 05..24 kept. for i := 0; i < 5; i++ { - name := fmt.Sprintf("keeptui-2026-07-17_%02d-00-00.log", i) + name := fmt.Sprintf("keepkit-2026-07-17_%02d-00-00.log", i) if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { t.Errorf("expected %s removed", name) } } for i := 5; i < 25; i++ { - name := fmt.Sprintf("keeptui-2026-07-17_%02d-00-00.log", i) + name := fmt.Sprintf("keepkit-2026-07-17_%02d-00-00.log", i) if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("expected %s kept: %v", name, err) } @@ -51,14 +51,14 @@ func TestCleanupLeavesForeignFiles(t *testing.T) { defer restore() for i := 0; i < 25; i++ { - seed(t, dir, fmt.Sprintf("keeptui-2026-07-17_%02d-00-00.log", i)) + seed(t, dir, fmt.Sprintf("keepkit-2026-07-17_%02d-00-00.log", i)) } seed(t, dir, "notes.txt") - seed(t, dir, "keeptui-x.txt") // wrong suffix + seed(t, dir, "keepkit-x.txt") // wrong suffix Cleanup() - for _, name := range []string{"notes.txt", "keeptui-x.txt"} { + for _, name := range []string{"notes.txt", "keepkit-x.txt"} { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("foreign file %s should be untouched: %v", name, err) } @@ -79,7 +79,7 @@ func TestCleanupFewerThan20(t *testing.T) { defer restore() for i := 0; i < 5; i++ { - seed(t, dir, fmt.Sprintf("keeptui-2026-07-17_%02d-00-00.log", i)) + seed(t, dir, fmt.Sprintf("keepkit-2026-07-17_%02d-00-00.log", i)) } Cleanup() diff --git a/internal/logx/logx.go b/internal/logx/logx.go index 587ae9b..61bc879 100644 --- a/internal/logx/logx.go +++ b/internal/logx/logx.go @@ -1,5 +1,5 @@ // Package logx is a dependency-free, errors-only session logger. It writes one -// plain-text file per session under ~/.config/keeptui/logs, created lazily on the +// plain-text file per session under ~/.config/keepkit/logs, created lazily on the // first write. A session with no errors leaves no file at all — the presence of // a file is itself the signal that something went wrong. // @@ -17,7 +17,7 @@ import ( "sync" "time" - "github.com/stanlyzoolo/keeptui/internal/configdir" + "github.com/stanlyzoolo/keepkit/internal/configdir" ) var ( @@ -29,7 +29,7 @@ var ( dirOverride string // test seam; empty in production ) -// logDir resolves /keeptui/logs, honoring the test override. +// logDir resolves /keepkit/logs, honoring the test override. // Mirrors loader.MetaPath's resolution. Caller holds mu. func logDir() string { if dirOverride != "" { @@ -39,7 +39,7 @@ func logDir() string { if err != nil { return "" } - return filepath.Join(base, "keeptui", "logs") + return filepath.Join(base, "keepkit", "logs") } // SetDirForTesting redirects the log directory and resets all logger state, so @@ -101,7 +101,7 @@ func Errorf(format string, args ...any) { failed = true return } - name := "keeptui-" + time.Now().Format("2006-01-02_15-04-05") + ".log" + name := "keepkit-" + time.Now().Format("2006-01-02_15-04-05") + ".log" f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { failed = true @@ -142,7 +142,7 @@ func Path() string { return path } -// ReadAllForTesting returns the concatenated contents of every keeptui-*.log in +// ReadAllForTesting returns the concatenated contents of every keepkit-*.log in // dir, or "" when the directory is absent. It is the shared reader for tests // that assert what the logger wrote (paired with SetDirForTesting), so each // package's test file does not re-implement the scan. @@ -153,7 +153,7 @@ func ReadAllForTesting(dir string) string { } var sb strings.Builder for _, e := range entries { - if strings.HasPrefix(e.Name(), "keeptui-") && strings.HasSuffix(e.Name(), ".log") { + if strings.HasPrefix(e.Name(), "keepkit-") && strings.HasSuffix(e.Name(), ".log") { data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { continue diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go index 74b76ce..0214933 100644 --- a/internal/logx/logx_test.go +++ b/internal/logx/logx_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// logFiles returns the keeptui-*.log entries in dir. +// logFiles returns the keepkit-*.log entries in dir. func logFiles(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) @@ -20,7 +20,7 @@ func logFiles(t *testing.T, dir string) []string { } var out []string for _, e := range entries { - if strings.HasPrefix(e.Name(), "keeptui-") && strings.HasSuffix(e.Name(), ".log") { + if strings.HasPrefix(e.Name(), "keepkit-") && strings.HasSuffix(e.Name(), ".log") { out = append(out, e.Name()) } } @@ -45,7 +45,7 @@ func TestFirstErrorfCreatesFileWithHeader(t *testing.T) { restore := SetDirForTesting(dir) defer restore() - SetHeader("keeptui v1.4.0 darwin/arm64 tools=12 token=config") + SetHeader("keepkit v1.4.0 darwin/arm64 tools=12 token=config") Errorf("something %s", "broke") files := logFiles(t, dir) @@ -60,7 +60,7 @@ func TestFirstErrorfCreatesFileWithHeader(t *testing.T) { if len(lines) != 2 { t.Fatalf("expected 2 lines (header + record), got %d: %q", len(lines), string(data)) } - if lines[0] != "keeptui v1.4.0 darwin/arm64 tools=12 token=config" { + if lines[0] != "keepkit v1.4.0 darwin/arm64 tools=12 token=config" { t.Errorf("header line = %q", lines[0]) } if !strings.Contains(lines[1], "ERROR something broke") { diff --git a/internal/model/cardlinks_test.go b/internal/model/cardlinks_test.go index 0988457..1c1ee17 100644 --- a/internal/model/cardlinks_test.go +++ b/internal/model/cardlinks_test.go @@ -7,8 +7,8 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/version" ) const ( diff --git a/internal/model/commands.go b/internal/model/commands.go index 9c7ba00..b2922eb 100644 --- a/internal/model/commands.go +++ b/internal/model/commands.go @@ -12,12 +12,12 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/launcher" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/proc" - "github.com/stanlyzoolo/keeptui/internal/updater" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/launcher" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/proc" + "github.com/stanlyzoolo/keepkit/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/version" ) // safeCmd wraps a command so a panic in its goroutine is recorded to the session @@ -68,8 +68,8 @@ type launchDoneMsg struct { } // execDoneMsg is the tea.ExecProcess callback result for the fallback path: -// the tool ran in the current window and keeptui resumed. err is the tool's -// own exit status — a tool exiting non-zero is not a keeptui anomaly, so the +// the tool ran in the current window and keepkit resumed. err is the tool's +// own exit status — a tool exiting non-zero is not a keepkit anomaly, so the // handler surfaces it as a statusMsg and never logs it. type execDoneMsg struct { toolName string @@ -119,7 +119,7 @@ func shellCommand(goos, cmd string) (string, []string) { } // execToolCmd runs command in the current window via tea.ExecProcess: Bubble -// Tea releases the terminal, the tool takes it over, and keeptui resumes when +// Tea releases the terminal, the tool takes it over, and keepkit resumes when // it exits. This is the fallback for terminals with no tab-scripting API and // the safety net when an adapter fails. Deliberately not wrapped in safeCmd: // tea.ExecProcess only builds the exec message — nothing here can panic. @@ -223,7 +223,7 @@ func fetchRateCmd() tea.Cmd { }) } -// selfCheckCmd fetches keeptui's own latest release tag — one release-only +// selfCheckCmd fetches keepkit'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. @@ -415,7 +415,7 @@ func (m *Model) autoFetchCmdsForSelected() tea.Cmd { if mt, ok := m.selectedMeta(); ok { switch { case m.showsUpdateLog(): - // A live update log owns [3] — this tool's, or keeptui's own, which is + // A live update log owns [3] — this tool's, or keepkit'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 @@ -612,7 +612,7 @@ func fetchHelpCmd(name string, mode int) tea.Cmd { // instead of masquerading as the other. // Every probe runs detached from the controlling terminal: a tool // that answers --help/-h/help by booting its own TUI would otherwise - // grab /dev/tty and shred keeptui's screen (see internal/proc). + // grab /dev/tty and shred keepkit's screen (see internal/proc). if mode == helpModeMan { cmd := exec.CommandContext(ctx, "man", name) cmd.Env = append(os.Environ(), "MANPAGER=cat", "MANWIDTH=80", "TERM=dumb") diff --git a/internal/model/commands_test.go b/internal/model/commands_test.go index d03b562..19cda12 100644 --- a/internal/model/commands_test.go +++ b/internal/model/commands_test.go @@ -11,10 +11,10 @@ import ( 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" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/version" ) func TestFetchHelpTakeoverLogs(t *testing.T) { @@ -319,7 +319,7 @@ func seedReadmeCache(t *testing.T, repo, readme string) { if err != nil { t.Fatalf("UserConfigDir: %v", err) } - if err := os.MkdirAll(filepath.Join(base, "keeptui"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(base, "keepkit"), 0o755); err != nil { t.Fatalf("mkdir cache: %v", err) } version.SaveCache(version.Cache{repo: { diff --git a/internal/model/group_test.go b/internal/model/group_test.go index 9ef2c16..36b9cd5 100644 --- a/internal/model/group_test.go +++ b/internal/model/group_test.go @@ -8,7 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) // groupTestModel builds a ready model over five tools in three tag groups diff --git a/internal/model/launch_test.go b/internal/model/launch_test.go index fa78afe..0dfe539 100644 --- a/internal/model/launch_test.go +++ b/internal/model/launch_test.go @@ -11,8 +11,8 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/launcher" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/launcher" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // assertExecMsg pins the ExecProcess path: the cmd's message must be Bubble @@ -99,7 +99,7 @@ func TestStartLaunchCmd(t *testing.T) { } }) t.Run("missing adapter binary", func(t *testing.T) { - msg, ok := startLaunchCmd(launcher.Plan{Argv: []string{"keeptui-no-such-adapter-xyz"}}, "git", "git status")().(launchDoneMsg) + msg, ok := startLaunchCmd(launcher.Plan{Argv: []string{"keepkit-no-such-adapter-xyz"}}, "git", "git status")().(launchDoneMsg) if !ok { t.Fatalf("unexpected msg type %T", msg) } @@ -180,7 +180,7 @@ func TestRunInputDispatchAdapter(t *testing.T) { t.Skip("invoking the adapter cmd needs a unix tmux/exec environment") } clearTerminalEnv(t) - t.Setenv("TMUX", "/nonexistent/keeptui-test-socket,99999,0") + t.Setenv("TMUX", "/nonexistent/keepkit-test-socket,99999,0") m := newTestModel(focusTools) nm, cmd := enterRun(m, "git status") if got := nm.lastRun["git"]; got != "git status" { @@ -435,7 +435,7 @@ func TestLaunchDoneMsgSuccess(t *testing.T) { } // TestExecDoneMsg: the ExecProcess callback result. A non-zero exit surfaces -// as a statusMsg and is never logged (the tool's exit status is not a keeptui +// as a statusMsg and is never logged (the tool's exit status is not a keepkit // anomaly); a clean exit is silent. func TestExecDoneMsg(t *testing.T) { shrinkStatusTTL(t) diff --git a/internal/model/main_test.go b/internal/model/main_test.go index 37f7c7a..2988075 100644 --- a/internal/model/main_test.go +++ b/internal/model/main_test.go @@ -4,14 +4,14 @@ import ( "os" "testing" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/version" ) // TestMain redirects logx to a throwaway directory for the whole package test // binary, so tests that exercise the logging paths (fetchHelpCmd, safeCmd -// panics) never write keeptui-*.log into the real user config dir. Individual +// panics) never write keepkit-*.log into the real user config dir. Individual // tests that assert logger output still call logx.SetDirForTesting with their // own temp dir; its restore reverts to this fallback (not the real dir). // @@ -22,12 +22,12 @@ import ( // each test to remember its own temp HOME is what let an ad-hoc probe overwrite // a real tracker; TestConfigDirIsolated pins that this never regresses. func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "keeptui-model-logs") + dir, err := os.MkdirTemp("", "keepkit-model-logs") if err != nil { panic(err) } restore := logx.SetDirForTesting(dir) - cfgDir, err := os.MkdirTemp("", "keeptui-model-config") + cfgDir, err := os.MkdirTemp("", "keepkit-model-config") if err != nil { panic(err) } diff --git a/internal/model/mode.go b/internal/model/mode.go index df86d98..e8dce7f 100644 --- a/internal/model/mode.go +++ b/internal/model/mode.go @@ -7,10 +7,10 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/launcher" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/updater" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/launcher" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/version" ) // inputMode is the single input/modal state of the TUI. Exactly one mode is @@ -408,7 +408,7 @@ func flushPendingLaunch(mdl tea.Model, cmd tea.Cmd) (tea.Model, tea.Cmd) { // 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: +// m.updateTarget, so there is one path here for a tool and for keepkit 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. @@ -424,12 +424,12 @@ func (m Model) updateConfirmUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updatingFor = target m.updateLog = nil m.updateLogFor = target - // keeptui's own log owns [3] under every selection — an untracked keeptui + // keepkit's own log owns [3] under every selection — an untracked keepkit // 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 + // with the self-update off, keepkit's row keeps the same per-tool stickiness // as any other row. m.selfUpdateLog = m.selfUpdating() m.briefViewport.SetContent(m.renderCard()) diff --git a/internal/model/mode_test.go b/internal/model/mode_test.go index 366ad3e..5566d49 100644 --- a/internal/model/mode_test.go +++ b/internal/model/mode_test.go @@ -8,8 +8,8 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/updater" ) func keyRunes(s string) tea.KeyMsg { diff --git a/internal/model/model.go b/internal/model/model.go index 91e6d82..e4f094e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -12,11 +12,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/ui" - "github.com/stanlyzoolo/keeptui/internal/updater" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/ui" + "github.com/stanlyzoolo/keepkit/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/version" ) const ( @@ -128,21 +128,21 @@ const updateLogMaxLines = 500 // 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: +// selfToolName is the name keepkit 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" +// meta.yaml lookup — the feature's main case is a keepkit that is not tracked. +const selfToolName = "keepkit" // 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 +// selfOffered full banner: "keepkit available — [U] update [X] dismiss" +// selfDismissed collapsed to a compact "keepkit ↑ [U]" cell by the gauge +// selfUpdated full banner: "keepkit updated — [U] restart [X] later" +// selfUpdatedLater collapsed to a compact "keepkit [U] restart" cell // // Five sites switch on it — [U] (selfUpdateKey), [X], selfBannerCells, // selfCompactCell and the [?] Self group — and .golangci.yml carries no @@ -162,7 +162,7 @@ const ( selfStateCount ) -// selfCheckMsg carries the result of the startup self-check — keeptui's own +// selfCheckMsg carries the result of the startup self-check — keepkit'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 @@ -179,8 +179,8 @@ type selfCheckMsg struct { // // 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 +// relevant: a tool result must match the selection, while keepkit's own has no +// selection to match (the main case is a keepkit that is not tracked) — see // acceptsUpdateDetect. type updateDetectedMsg struct { tool string @@ -296,7 +296,7 @@ type Model struct { // 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 + // keepkit'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 @@ -315,10 +315,10 @@ type Model struct { // (--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 + // selfLatest is keepkit'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 + // keepkit's own, so it stays visible regardless of the selection — an + // untracked keepkit has no row to select. restartRequested is the exit flag // main reads off the model p.Run() returns. selfLatest string selfState selfState @@ -512,16 +512,16 @@ func isDevVersion(v string) bool { return pseudoVersionRe.MatchString(v) } -// isSelfUpdate reports whether an update of the named tool is keeptui's own +// isSelfUpdate reports whether an update of the named tool is keepkit'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 +// [U] restart offer — rather than a plain tool update. Every update of keepkit 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 +// nothing to compare, so [u] on a tracked keepkit 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 @@ -530,17 +530,17 @@ func (m Model) isSelfUpdate(name string) bool { return name == selfToolName && m.selfCheckEnabled() } -// selfUpdating reports whether the update currently in flight is keeptui's own. +// selfUpdating reports whether the update currently in flight is keepkit'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 +// selfTool is the updater's detection target for keepkit itself. A tracked +// keepkit 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 +// synthesized — the feature's main case is a keepkit 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 { @@ -557,8 +557,8 @@ func (m Model) selfTool() loader.Tool { // 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. +// keepkit's own, which stays visible regardless of the selection because an +// untracked keepkit 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 @@ -579,7 +579,7 @@ func (m Model) showsUpdateLog() bool { // 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 +// Only the override is dropped, not updateLogFor: a tracked keepkit keeps the // same per-tool stickiness every other tool has. func (m *Model) dismissSelfLog() bool { if !m.selfUpdateLog || m.selfUpdating() { @@ -640,7 +640,7 @@ func (m *Model) selfUpdateKey() tea.Cmd { // 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 +// while keepkit'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 { @@ -902,7 +902,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // On failure, auto-fall back to running the tool in the current // window — the tool always launches, the status bar explains the // degradation. Not logged: the fallback makes this a degraded path, - // not a keeptui malfunction. + // not a keepkit malfunction. if msg.err != nil { // The result can arrive up to launchTimeout after enter (osascript // blocked on the macOS Automation dialog). tea.ExecProcess seizes @@ -926,8 +926,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.setStatus("launched " + msg.toolName) case execDoneMsg: - // The tool ran in the current window and keeptui resumed. A non-zero - // exit belongs to the tool, not keeptui — statusMsg only, no logx. + // The tool ran in the current window and keepkit resumed. A non-zero + // exit belongs to the tool, not keepkit — statusMsg only, no logx. // Exception in wording only: the shell's own "command not found" // exit (notFoundExit) means the tool never ran, so the message says // "not installed" instead of surfacing a cryptic exit status. @@ -965,12 +965,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case updateDetectedMsg: - // Detection result for a [u] (tool) or [U] (keeptui itself) press; the + // Detection result for a [u] (tool) or [U] (keepkit 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 + // keepkit'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) { @@ -980,7 +980,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + // way from [U] and from [u] on a tracked keepkit row (see // isSelfUpdate). msg.self keeps the one meaning only it has — "no // selection to match" — in acceptsUpdateDetect. if m.isSelfUpdate(msg.tool) { @@ -1029,12 +1029,12 @@ 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: + // keepkit'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 + // which for keepkit 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 + // isSelfUpdate, not the bare name: an update of keepkit is a self-update + // whichever key started it (so [u] on a tracked keepkit 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. @@ -1046,7 +1046,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 + // announced "keepkit 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 @@ -1058,7 +1058,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + // only a tracked keepkit 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)) @@ -1964,7 +1964,7 @@ func (m *Model) setHelpContent() { m.helpEntries = nil m.helpNavIdx = -1 m.helpBase = "" - // The update log (a tool's or keeptui's own — showsUpdateLog covers both) and + // The update log (a tool's or keepkit'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 @@ -2000,7 +2000,7 @@ func (m *Model) setHelpContent() { 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 + // (otherwise sticky), keepkit'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. diff --git a/internal/model/mouse_test.go b/internal/model/mouse_test.go index 8d80c04..ee91ee9 100644 --- a/internal/model/mouse_test.go +++ b/internal/model/mouse_test.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) // newMouseTestModel builds a ready Model (viewports sized via WindowSizeMsg) diff --git a/internal/model/readme_test.go b/internal/model/readme_test.go index c1a56d8..0b229c6 100644 --- a/internal/model/readme_test.go +++ b/internal/model/readme_test.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -const sampleReadme = "# Title\n\nSome intro text.\n\n## Usage\n\n- first item\n- second item\n\n```sh\nkeeptui --version\n```\n" +const sampleReadme = "# Title\n\nSome intro text.\n\n## Usage\n\n- first item\n- second item\n\n```sh\nkeepkit --version\n```\n" // visibleLines strips ANSI from rendered output so assertions look at the text // a user sees, not at glamour's escape sequences (which vary by color profile). @@ -21,7 +21,7 @@ func TestRenderReadmeContent(t *testing.T) { t.Fatal("renderReadme returned empty output") } plain := stripANSI(out) - for _, want := range []string{"Title", "Some intro text.", "Usage", "first item", "second item", "keeptui --version"} { + for _, want := range []string{"Title", "Some intro text.", "Usage", "first item", "second item", "keepkit --version"} { if !strings.Contains(plain, want) { t.Errorf("rendered README missing %q\n---\n%s", want, plain) } diff --git a/internal/model/render.go b/internal/model/render.go index 9b4e348..b3f083f 100644 --- a/internal/model/render.go +++ b/internal/model/render.go @@ -12,10 +12,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/ui" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/ui" + "github.com/stanlyzoolo/keepkit/internal/version" ) func (m Model) View() string { @@ -118,7 +118,7 @@ func (m Model) renderStatusBar() string { } if m.mode == modeConfirmUpdate { // 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 + // keepkit'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( @@ -294,8 +294,8 @@ func (m Model) renderHintsBar(style lipgloss.Style, cells []string) string { // 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 +// "keepkit updating…" cell in the right group is the only in-flight indicator +// besides the live log in [3] (an untracked keepkit 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() { @@ -1061,7 +1061,7 @@ func (m Model) renderHelp() string { case helpModeReadme: title = "[3] Readme" } - // While an update log is showing — the selected tool's, or keeptui's own, + // While an update log is showing — the selected tool's, or keepkit's own, // which is selection-independent — the panel is the log, not help. Mirror // that in the inset title. if m.showsUpdateLog() { @@ -1547,7 +1547,7 @@ func (m Model) renderHelpContent() string { // 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 + // self-update's log is not tied to a selection at all — keepkit is typically // untracked and the tracker may even be empty. The buffer survives until the // next update starts. if m.showsUpdateLog() { diff --git a/internal/model/render_test.go b/internal/model/render_test.go index fba964a..5e19509 100644 --- a/internal/model/render_test.go +++ b/internal/model/render_test.go @@ -19,10 +19,10 @@ import ( "github.com/mattn/go-runewidth" "github.com/muesli/termenv" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/ui" - "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/ui" + "github.com/stanlyzoolo/keepkit/internal/version" ) // TestUpdateViewNoPanicNoLog confirms a normal Update/View cycle writes no log @@ -42,7 +42,7 @@ func TestUpdateViewNoPanicNoLog(t *testing.T) { if entries, err := os.ReadDir(logDir); err == nil { for _, e := range entries { - if strings.HasPrefix(e.Name(), "keeptui-") { + if strings.HasPrefix(e.Name(), "keepkit-") { t.Errorf("normal Update/View cycle created a log file: %s", e.Name()) } } @@ -3691,7 +3691,7 @@ func TestStatusBarNeverWraps(t *testing.T) { // 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. + // offered banner instead of the compact "keepkit 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 diff --git a/internal/model/scroll_test.go b/internal/model/scroll_test.go index f04d53b..7eb1db6 100644 --- a/internal/model/scroll_test.go +++ b/internal/model/scroll_test.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) // ctrlKey builds a ctrl+ key message whose String() matches the switch cases diff --git a/internal/model/selfupdate_test.go b/internal/model/selfupdate_test.go index 413f461..b3842ec 100644 --- a/internal/model/selfupdate_test.go +++ b/internal/model/selfupdate_test.go @@ -9,10 +9,10 @@ import ( 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" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/version" ) // ---- fixtures ---- @@ -46,7 +46,7 @@ func selfModel(t *testing.T, meta []loader.ToolMeta, width int, state selfState) } // 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 +// tool, deliberately not named keepkit, so every "keepkit" 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"}} @@ -65,7 +65,7 @@ func startedSelfUpdate(t *testing.T, meta []loader.ToolMeta, state selfState) Mo return m } -// seedSelfReleaseCache adds a fresh release-only cache entry for keeptui's own +// seedSelfReleaseCache adds a fresh release-only cache entry for keepkit'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. @@ -382,9 +382,9 @@ func TestSelfStateSitesAreExhaustive(t *testing.T) { // 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 +// keepkit 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. +// a build with the feature off an update of keepkit is a plain tool update. func TestSelfUpdatingPredicate(t *testing.T) { tests := []struct { name string @@ -395,9 +395,9 @@ func TestSelfUpdatingPredicate(t *testing.T) { {"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}, + {"keepkit updating on a dev build", "dev", selfToolName, false}, + {"keepkit updating on a pseudo-version build", "v0.0.0-20260725115912-1be4bafa79c8", selfToolName, false}, + {"keepkit updating with no version injected", "", selfToolName, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -429,41 +429,41 @@ func TestSelfBannerInStatusBar(t *testing.T) { name: "no banner", state: selfNone, want: []string{"[t] track"}, - absent: []string{"keeptui"}, + absent: []string{"keepkit"}, }, { name: "offer replaces the hints", state: selfOffered, - want: []string{"keeptui v0.5.0 available", "[U] update", "[X] dismiss"}, + want: []string{"keepkit 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]"}, + want: []string{"[t] track", "keepkit ↑ [U]"}, absent: []string{"available", "dismiss"}, - wantCell: "keeptui ↑ [U]", + wantCell: "keepkit ↑ [U]", }, { name: "updated offers the restart", state: selfUpdated, - want: []string{"keeptui updated", "[U] restart", "[X] later"}, + want: []string{"keepkit 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"}, + want: []string{"[t] track", "keepkit [U] restart"}, absent: []string{"later", "available"}, - wantCell: "keeptui [U] restart", + wantCell: "keepkit [U] restart", }, { name: "in flight shows neither banner", state: selfOffered, updating: true, - want: []string{"[t] track", "keeptui updating…"}, + want: []string{"[t] track", "keepkit updating…"}, absent: []string{"available", "[X] dismiss"}, - wantCell: "keeptui updating…", + wantCell: "keepkit updating…", }, } for _, tt := range tests { @@ -498,7 +498,7 @@ func TestSelfBannerInEveryNormalFocus(t *testing.T) { 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") { + if !strings.Contains(bar, "keepkit v0.5.0 available") || !strings.Contains(bar, "[U] update") { t.Errorf("focus %d bar = %q, want the self-update banner", focus, bar) } } @@ -514,7 +514,7 @@ func TestSelfBannerYieldsToStatusMsg(t *testing.T) { if !strings.Contains(bar, "no repo for rg") { t.Errorf("status bar = %q, want the transient message", bar) } - if strings.Contains(bar, "keeptui") { + if strings.Contains(bar, "keepkit") { t.Errorf("status bar = %q, want the banner suppressed under a status message", bar) } } @@ -528,14 +528,14 @@ func TestSelfCompactCellRightGroupDegradation(t *testing.T) { 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]") { + if !strings.Contains(bar, "GitHub API Usage") || !strings.Contains(bar, "keepkit ↑ [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]") { + if !strings.Contains(bar, "keepkit ↑ [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") { @@ -554,7 +554,7 @@ func TestSelfCompactCellFitsBaselineTerminal(t *testing.T) { m.rate = version.RateLimit{Known: true, Limit: 60, Remaining: 42} bar := stripANSI(m.renderStatusBar()) - if !strings.Contains(bar, "keeptui ↑ [U]") { + if !strings.Contains(bar, "keepkit ↑ [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. @@ -591,7 +591,7 @@ func TestRateGaugeUnaffectedWithoutSelfCell(t *testing.T) { 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 + // developer's own keepkit — TestSelfUpdateKeyDetectsSelf executes it under an // update_cmd override instead. const ( noCmd = "" @@ -658,10 +658,10 @@ func TestSelfKeys(t *testing.T) { } // TestSelfUpdateKeyDetectsSelf executes the command [U] returns: it must be a -// *self*-tagged detection of keeptui. Without this the two mutations that kill +// *self*-tagged detection of keepkit. 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. +// whenever keepkit 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 @@ -680,7 +680,7 @@ func TestSelfUpdateKeyDetectsSelf(t *testing.T) { 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") + t.Error("updateDetectedMsg.self = false — an untracked keepkit's result would be dropped") } if msg.tool != selfToolName { t.Errorf("updateDetectedMsg.tool = %q, want %q", msg.tool, selfToolName) @@ -688,11 +688,11 @@ func TestSelfUpdateKeyDetectsSelf(t *testing.T) { // 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) + direct, ok := detectUpdateCmd(loader.Tool{Name: "keepkit-no-such-binary"}, true)().(updateDetectedMsg) if !ok { t.Fatalf("detectUpdateCmd produced %T, want updateDetectedMsg", direct) } - if !direct.self || direct.tool != "keeptui-no-such-binary" { + if !direct.self || direct.tool != "keepkit-no-such-binary" { t.Errorf("detectUpdateCmd msg = %+v, want it tagged self for the given tool", direct) } if !errors.Is(direct.err, updater.ErrUnknownManager) { @@ -775,7 +775,7 @@ func TestSelfUpdateKeyInertWithoutBanner(t *testing.T) { // 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 +// one while keepkit updates itself. Same updatingFor guard, mirrored by // TestSelfUpdateKeyInertWithoutBanner's blocked rows for [U] under one. func TestToolUpdateKeyBlockedBySelfUpdate(t *testing.T) { shrinkStatusTTL(t) @@ -837,9 +837,9 @@ func TestSelfKeysAreNormalModeOnly(t *testing.T) { func TestSelfToolInheritsTrackedEntry(t *testing.T) { tracked := New([]loader.ToolMeta{ {Name: "rg"}, - {Name: selfToolName, GitHub: version.SelfRepo, UpdateCmd: "brew upgrade keeptui"}, + {Name: selfToolName, GitHub: version.SelfRepo, UpdateCmd: "brew upgrade keepkit"}, }).selfTool() - if tracked.Name != selfToolName || tracked.UpdateCmd != "brew upgrade keeptui" { + if tracked.Name != selfToolName || tracked.UpdateCmd != "brew upgrade keepkit" { t.Errorf("selfTool() = %#v, want the tracked entry with its update_cmd", tracked) } @@ -847,7 +847,7 @@ func TestSelfToolInheritsTrackedEntry(t *testing.T) { // 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) + t.Errorf("selfTool() = %#v, want a synthesized keepkit entry", synthetic) } if loader.NormalizeRepo(synthetic.GitHub) != version.SelfRepo { t.Errorf("NormalizeRepo(%q) = %q, want %q", @@ -856,11 +856,11 @@ func TestSelfToolInheritsTrackedEntry(t *testing.T) { } // TestSelfDetectedAcceptance: a self detection result has no selection to match -// (keeptui is typically untracked, the tracker may be empty), so it is gated on +// (keepkit 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"} + plan := updater.Plan{Manager: "brew", Argv: []string{"brew", "upgrade", "keepkit"}, Display: "brew upgrade keepkit"} tests := []struct { name string meta []loader.ToolMeta @@ -928,7 +928,7 @@ func TestSelfDetectedAcceptance(t *testing.T) { // 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, +// asked: keepkit'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) { @@ -939,9 +939,9 @@ func TestSelfDetectedUnknownManager(t *testing.T) { 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 + // [u] on a tracked keepkit 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"}, + {name: "u on a tracked keepkit 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"}, @@ -975,7 +975,7 @@ func TestSelfDetectedUnknownManager(t *testing.T) { 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.updatePlan = updater.Plan{Manager: "brew", Argv: []string{"true"}, Display: "brew upgrade keepkit"} m.updateTarget = selfToolName m.updateLog = []string{"stale output from a previous run"} @@ -1003,7 +1003,7 @@ func TestSelfConfirmEnterStartsWithEmptyTracker(t *testing.T) { 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.updatePlan = updater.Plan{Argv: []string{"true"}, Display: "brew upgrade keepkit"} m.updateTarget = selfToolName m = mustModel(m.Update(tea.KeyMsg{Type: tea.KeyEsc})) @@ -1039,19 +1039,19 @@ func TestToolConfirmEnterReleasesSelfLog(t *testing.T) { } } -// TestSelfConfirmBarNamesKeeptui: the confirm bar names the plan's own target, -// which for keeptui's update is neither the selected tool nor — with an empty +// TestSelfConfirmBarNamesKeepkit: the confirm bar names the plan's own target, +// which for keepkit's update is neither the selected tool nor — with an empty // tracker — anything at all. -func TestSelfConfirmBarNamesKeeptui(t *testing.T) { +func TestSelfConfirmBarNamesKeepkit(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.updatePlan = updater.Plan{Display: "brew upgrade keepkit"} 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 keepkit: brew upgrade keepkit") { + t.Errorf("tracker %v: confirm bar = %q, want it to name keepkit", meta, bar) } if strings.Contains(bar, "update rg") { t.Errorf("tracker %v: confirm bar = %q, want no selected-tool name", meta, bar) @@ -1071,33 +1071,33 @@ func TestSelfConfirmBarNamesKeeptui(t *testing.T) { // ---- 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 — +// toolByName early return, so an untracked keepkit'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"} + m.updateLog = []string{"==> Upgrading keepkit", "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.statusMsg != "updated keepkit" { + t.Errorf("statusMsg = %q, want %q", m.statusMsg, "updated keepkit") } 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 + // Nothing to re-detect: an untracked keepkit 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 ↑ +// TestSelfUpdateDoneSuccessTracked: a tracked keepkit 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. +// keepkit 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) @@ -1108,8 +1108,8 @@ func TestSelfUpdateDoneSuccessTracked(t *testing.T) { 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.statusMsg != "updated keepkit" { + t.Errorf("statusMsg = %q, want %q", m.statusMsg, "updated keepkit") } if m.selfUpdating() { t.Error("selfUpdating() = true after completion, want the guard cleared") @@ -1180,7 +1180,7 @@ func TestUpdateFailureSeedsOnlyItsOwnLog(t *testing.T) { 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) + t.Errorf("updateLog = %#v, want rg's buffer untouched by keepkit'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") { @@ -1194,11 +1194,11 @@ func TestUpdateFailureSeedsOnlyItsOwnLog(t *testing.T) { // 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 +// selfNone — reachable with no [U] press at all, since [u] on a tracked keepkit 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 +// rendered a banner with a hole in it ("keepkit available") that no later // selfCheckMsg could fill, the handler writing only from selfNone. func TestSelfUpdateDoneFailureKeepsPriorState(t *testing.T) { logDir := t.TempDir() @@ -1212,11 +1212,11 @@ func TestSelfUpdateDoneFailureKeepsPriorState(t *testing.T) { wantContains string wantAbsent string }{ - {name: "never offered", state: selfNone, wantAbsent: "keeptui"}, + {name: "never offered", state: selfNone, wantAbsent: "keepkit"}, {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"}, + {name: "folded offer", state: selfDismissed, latest: "v0.5.0", wantContains: "keepkit ↑"}, + {name: "restart pending", state: selfUpdated, wantContains: "keepkit updated"}, + {name: "restart folded", state: selfUpdatedLater, wantContains: "keepkit [U] restart"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1241,7 +1241,7 @@ func TestSelfUpdateDoneFailureKeepsPriorState(t *testing.T) { } } -// TestKeeptuiUpdateSelfHandlingGatedOnBuild: [u] on a tracked keeptui row reaches +// TestKeepkitUpdateSelfHandlingGatedOnBuild: [u] on a tracked keepkit 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 @@ -1249,7 +1249,7 @@ func TestSelfUpdateDoneFailureKeepsPriorState(t *testing.T) { // 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) { +func TestKeepkitUpdateSelfHandlingGatedOnBuild(t *testing.T) { tests := []struct { name string appVersion string @@ -1270,10 +1270,10 @@ func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { t.Fatalf("selfState = %v, want selfNone before anything ran", m.selfState) } - // The tool route, exactly as [u] on the keeptui row leaves it. + // The tool route, exactly as [u] on the keepkit row leaves it. m.mode = modeConfirmUpdate m.updateTarget = selfToolName - m.updatePlan = updater.Plan{Manager: "brew", Argv: []string{"true"}, Display: "brew upgrade keeptui"} + m.updatePlan = updater.Plan{Manager: "brew", Argv: []string{"true"}, Display: "brew upgrade keepkit"} 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", @@ -1281,10 +1281,10 @@ func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { } // The batch is deliberately not executed: fetchInstalledCmd for this name - // would run the developer's own keeptui binary. + // would run the developer's own keepkit 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") + if m.statusMsg != "updated keepkit" { + t.Errorf("statusMsg = %q, want %q on both paths", m.statusMsg, "updated keepkit") } wantState := selfNone if tt.wantSelf { @@ -1299,7 +1299,7 @@ func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { overlay := stripANSI(m.renderHotkeys()) m = mustModel(m.Update(keyRunes("U"))) if tt.wantSelf { - if !strings.Contains(bar, "keeptui updated") { + if !strings.Contains(bar, "keepkit updated") { t.Errorf("status bar = %q, want the restart offer", bar) } if !strings.Contains(overlay, "restart") { @@ -1310,7 +1310,7 @@ func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { } return } - if strings.Contains(bar, "keeptui updated") || strings.Contains(bar, "[U]") { + if strings.Contains(bar, "keepkit 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") { @@ -1320,7 +1320,7 @@ func TestKeeptuiUpdateSelfHandlingGatedOnBuild(t *testing.T) { 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. + // the keepkit 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") @@ -1375,14 +1375,14 @@ func TestShowsUpdateLog(t *testing.T) { } // TestSelfUpdateLogOwnsHelpPanel: the log and the [3] Update title are reachable -// with keeptui untracked and even with an empty tracker — where selectedMeta, +// with keepkit 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"} + m.updateLog = []string{"==> Upgrading keepkit"} - if content := stripANSI(m.renderHelpContent()); !strings.Contains(content, "==> Upgrading keeptui") { + if content := stripANSI(m.renderHelpContent()); !strings.Contains(content, "==> Upgrading keepkit") { t.Errorf("tracker %v: [3] = %q, want the self-update log", meta, content) } if panel := stripANSI(m.renderHelp()); !strings.Contains(panel, "[3] Update") { @@ -1446,7 +1446,7 @@ func TestSelfUpdateLogReleasedWhenDone(t *testing.T) { } // TestSelfUpdateLogSuppressesHelpNav covers the setHelpContent site of -// showsUpdateLog: keeptui's own log owns [3] regardless of the selection, so the +// showsUpdateLog: keepkit'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) { @@ -1472,7 +1472,7 @@ func TestSelfUpdateLogSuppressesHelpNav(t *testing.T) { m.updatingFor = selfToolName m.updateLogFor = selfToolName m.selfUpdateLog = true - m.updateLog = []string{"==> Upgrading keeptui"} + m.updateLog = []string{"==> Upgrading keepkit"} m.setHelpContent() if len(m.helpEntries) != 0 { @@ -1481,7 +1481,7 @@ func TestSelfUpdateLogSuppressesHelpNav(t *testing.T) { 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") { + if content := stripANSI(m.renderHelpContent()); !strings.Contains(content, "==> Upgrading keepkit") { t.Errorf("[3] = %q, want the self-update log", content) } // With no entries j is plain scroll, so the spotlight cursor stays off. @@ -1497,13 +1497,13 @@ func TestSelfUpdateLogSuppressesHelpNav(t *testing.T) { func TestSelfUpdateLogSkipsHelpFetch(t *testing.T) { live := startedSelfUpdate(t, []loader.ToolMeta{{Name: "rg"}}, selfOffered) live.helpMode = helpModeHelp - live.updateLog = []string{"==> Upgrading keeptui"} + live.updateLog = []string{"==> Upgrading keepkit"} 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") { + if content := stripANSI(live.renderHelpContent()); !strings.Contains(content, "==> Upgrading keepkit") { t.Errorf("[3] = %q, want the self-update log", content) } diff --git a/internal/model/textutil.go b/internal/model/textutil.go index 1e1f6df..887f287 100644 --- a/internal/model/textutil.go +++ b/internal/model/textutil.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/ui" + "github.com/stanlyzoolo/keepkit/internal/ui" ) // formatStars formats a star count with K suffix for thousands. @@ -298,7 +298,7 @@ var helpEntryFlagRe = regexp.MustCompile(`^\s*--?[a-zA-Z]`) // helpEntrySubcmdRe: an indented subcommand row — a word not starting with a // dash followed by 2+ spaces and description text (the shape of cobra/clap -// command blocks). Indentation is required so unindented prose ("Usage: keeptui +// command blocks). Indentation is required so unindented prose ("Usage: keepkit // [flags]", section text) never starts an entry; the word class has no ':' so // inline labels like "Note:" don't match, and no '.' so justified man-page // prose ("tree. See also git-log(1)" — two spaces after a sentence period) diff --git a/internal/model/update_test.go b/internal/model/update_test.go index 7f28029..2f3a047 100644 --- a/internal/model/update_test.go +++ b/internal/model/update_test.go @@ -8,8 +8,8 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/updater" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/updater" ) // feedChunk drives one updateChunkMsg through Update and returns the new model. diff --git a/internal/proc/detach_unix.go b/internal/proc/detach_unix.go index e816042..e5bd66f 100644 --- a/internal/proc/detach_unix.go +++ b/internal/proc/detach_unix.go @@ -3,7 +3,7 @@ // Package proc hardens tool subprocesses (help/version probes) against // misbehaving CLI tools. A tracked tool that ignores its arguments and boots // a TUI (ratatui/crossterm, tcell, bubbletea …) opens /dev/tty directly and -// toggles raw mode / the alternate screen on the terminal keeptui itself is +// toggles raw mode / the alternate screen on the terminal keepkit itself is // drawing on, tearing the UI apart. Detaching the child from the controlling // terminal makes that open fail (ENXIO), so such a tool exits immediately and // harmlessly instead. @@ -15,7 +15,7 @@ import ( ) // DetachTTY runs cmd in its own session so it has no controlling terminal: -// any attempt to open /dev/tty fails instead of reaching keeptui's screen. +// any attempt to open /dev/tty fails instead of reaching keepkit's screen. func DetachTTY(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } diff --git a/internal/proc/detach_windows.go b/internal/proc/detach_windows.go index 6a91df8..80de70e 100644 --- a/internal/proc/detach_windows.go +++ b/internal/proc/detach_windows.go @@ -9,7 +9,7 @@ import ( // DetachTTY starts cmd without a console (DETACHED_PROCESS), the Windows // analog of dropping the controlling terminal: a child that tries to draw a -// TUI cannot reach keeptui's console via CONOUT$. Captured stdout/stderr pipes +// TUI cannot reach keepkit's console via CONOUT$. Captured stdout/stderr pipes // keep working. func DetachTTY(cmd *exec.Cmd) { const detachedProcess = 0x00000008 diff --git a/internal/ui/status.go b/internal/ui/status.go index 71c5f9e..19073a8 100644 --- a/internal/ui/status.go +++ b/internal/ui/status.go @@ -2,7 +2,7 @@ package ui import ( "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) func StatusStyle(s loader.Status) lipgloss.Style { diff --git a/internal/ui/status_test.go b/internal/ui/status_test.go index 110b954..009e6e6 100644 --- a/internal/ui/status_test.go +++ b/internal/ui/status_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/charmbracelet/lipgloss" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) func TestStatusStyle(t *testing.T) { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 171f847..c87a56b 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -15,8 +15,8 @@ import ( "strings" "time" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/proc" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/proc" ) // ErrUnknownManager is returned when no package manager can be attributed to a @@ -142,6 +142,16 @@ func Detect(t loader.Tool) (Plan, error) { plan, err := detectFromPath(realPath, buildinfo) if err != nil { + // The chain can exhaust with the binary found: a Homebrew cask app keeps + // its launcher on PATH while the executable lives inside the .app bundle + // (agterm), which matches neither the Cellar regex nor any other manager. + // The same self-validating brew-by-name check as the LookPath miss above: + // it fires only when a keg/cask directory of that name actually exists. + if errors.Is(err, ErrUnknownManager) { + if plan, ok := brewNamePlan(t.Name); ok { + return plan, nil + } + } return Plan{}, err } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index e9b0ad0..aeb6664 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/stanlyzoolo/keeptui/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/loader" ) func TestDetectFromPath(t *testing.T) { @@ -229,6 +229,44 @@ func TestDetectBrewByName(t *testing.T) { } } +// TestDetectBrewByNameChainExhausted pins the second brew-by-name site: the +// binary IS on PATH but belongs to no known manager — a Homebrew cask app whose +// executable lives inside the .app bundle (agterm), matching neither the Cellar +// regex nor any other chain step. The keg/cask directory makes +// `brew upgrade ` self-validating here exactly as on the LookPath miss. +func TestDetectBrewByNameChainExhausted(t *testing.T) { + prefix := t.TempDir() + mustMkdirAll(t, filepath.Join(prefix, "Caskroom", "someapp", "0.15.1")) + setTestBrewPrefix(t, prefix) + + // Real executables on PATH whose paths match no manager pattern. + binDir := t.TempDir() + for _, name := range []string{"someapp", "orphan"} { + script := filepath.Join(binDir, name) + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", binDir) + + plan, err := Detect(loader.Tool{Name: "someapp"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if plan.Manager != "brew" { + t.Errorf("Manager = %q, want %q", plan.Manager, "brew") + } + wantArgv := []string{"brew", "upgrade", "someapp"} + if !equalStrings(plan.Argv, wantArgv) { + t.Errorf("Argv = %v, want %v", plan.Argv, wantArgv) + } + + // Without a keg of that name the exhausted chain still dead-ends. + if _, err := Detect(loader.Tool{Name: "orphan"}); !errors.Is(err, ErrUnknownManager) { + t.Fatalf("orphan err = %v, want ErrUnknownManager", err) + } +} + func mustMkdirAll(t *testing.T, dir string) { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/version/brew.go b/internal/version/brew.go index 332e8a9..9b315bd 100644 --- a/internal/version/brew.go +++ b/internal/version/brew.go @@ -33,7 +33,7 @@ func brewPrefix() string { // per-tool subdirectory (Caskroom//0.15.1, Cellar//14.1.0). // This serves apps with no --version CLI (casks like GUI/terminal apps) at // the cost of two ReadDir calls — no brew subprocess. Returns "" when the -// tool is not brew-managed or its keeptui name differs from the formula/cask +// tool is not brew-managed or its keepkit name differs from the formula/cask // name. func brewDirVersion(name string) string { prefix := brewPrefix() diff --git a/internal/version/brew_test.go b/internal/version/brew_test.go index 45eb089..01cb149 100644 --- a/internal/version/brew_test.go +++ b/internal/version/brew_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // makeBrewLayout builds /// entries in a diff --git a/internal/version/detect.go b/internal/version/detect.go index 4bf4052..0491a5b 100644 --- a/internal/version/detect.go +++ b/internal/version/detect.go @@ -10,9 +10,9 @@ import ( "golang.org/x/mod/semver" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/proc" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/proc" ) var versionRe = regexp.MustCompile(`v?(\d+\.\d+[\d.]*)`) @@ -57,7 +57,7 @@ func InstalledVersion(t loader.Tool) (string, bool) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // Detached from the controlling terminal: a tool that ignores - // --version/-V and starts a TUI must not reach keeptui's screen. + // --version/-V and starts a TUI must not reach keepkit's screen. proc.DetachTTY(cmd) out, err := cmd.CombinedOutput() cancel() diff --git a/internal/version/detect_test.go b/internal/version/detect_test.go index 8413c71..f462761 100644 --- a/internal/version/detect_test.go +++ b/internal/version/detect_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" ) func TestIsNewer(t *testing.T) { diff --git a/internal/version/github.go b/internal/version/github.go index e47d7a0..8178e58 100644 --- a/internal/version/github.go +++ b/internal/version/github.go @@ -12,9 +12,9 @@ import ( "sync" "time" - "github.com/stanlyzoolo/keeptui/internal/configdir" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/configdir" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" ) const cacheTTL = 24 * time.Hour @@ -772,7 +772,7 @@ func cacheFilePath() (string, error) { if err != nil { return "", err } - return filepath.Join(base, "keeptui", "cache.json"), nil + return filepath.Join(base, "keepkit", "cache.json"), nil } func LoadCache() Cache { diff --git a/internal/version/logx_test.go b/internal/version/logx_test.go index 98434f2..fcd67b8 100644 --- a/internal/version/logx_test.go +++ b/internal/version/logx_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // respWith builds a minimal *http.Response for classifyStatus tests, carrying a diff --git a/internal/version/main_test.go b/internal/version/main_test.go index f10bc13..35635ce 100644 --- a/internal/version/main_test.go +++ b/internal/version/main_test.go @@ -9,12 +9,12 @@ import ( "sync" "testing" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // TestMain redirects logx to a throwaway directory for the whole package test // binary, so tests that exercise the logging error paths (classifyStatus, doGH, -// LoadCache/SaveCache, InstalledVersion) never write keeptui-*.log into the real +// LoadCache/SaveCache, InstalledVersion) never write keepkit-*.log into the real // user config dir. Individual tests that assert logger output still call // logx.SetDirForTesting with their own temp dir; its restore reverts to this // fallback (not the real dir), keeping every test off the real config. @@ -24,19 +24,19 @@ import ( // like a test fixture would otherwise leak a version into InstalledVersion // results). Brew-specific tests install their own layout via makeBrewLayout. func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "keeptui-version-logs") + dir, err := os.MkdirTemp("", "keepkit-version-logs") if err != nil { panic(err) } restore := logx.SetDirForTesting(dir) - brewDir, err := os.MkdirTemp("", "keeptui-version-brew") + brewDir, err := os.MkdirTemp("", "keepkit-version-brew") if err != nil { panic(err) } testBrewPrefix = brewDir // Same blanket protection for the two files this package writes: cache.json // and the token. Per-test overrides nest inside it and restore back to it. - cfgDir, err := os.MkdirTemp("", "keeptui-version-config") + cfgDir, err := os.MkdirTemp("", "keepkit-version-config") if err != nil { panic(err) } @@ -124,7 +124,7 @@ func apiServer(t *testing.T, h apiHandlers) *apiHits { 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. + // ~/.config/keepkit/token and doGH sends it to this local server. t.Setenv("GITHUB_TOKEN", "") resetTokenState(t, t.TempDir()) t.Cleanup(func() { diff --git a/internal/version/selfcheck.go b/internal/version/selfcheck.go index 682a796..9cc2a47 100644 --- a/internal/version/selfcheck.go +++ b/internal/version/selfcheck.go @@ -5,26 +5,26 @@ import ( "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 +// SelfRepo is keepkit's own GitHub repository. The self-check does not depend on +// meta.yaml — an untracked keepkit is the feature's main case — so the ref is a // constant rather than a tool field. -const SelfRepo = "stanlyzoolo/keeptui" +const SelfRepo = "stanlyzoolo/keepkit" -// SelfLatest returns keeptui's own latest release tag. It is a release-only pass: +// SelfLatest returns keepkit'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 +// tracked keepkit 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 +// by wiping the tuple a tracked keepkit'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) { @@ -48,7 +48,7 @@ func SelfLatest() (string, error) { // 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. + // without destroying content a tracked keepkit's card shows. updateCacheEntry(repo, func(existing CacheEntry) CacheEntry { e := applyReleaseOutcome(existing, info, err) e.ReleaseCheckedAt = time.Now() diff --git a/internal/version/selfcheck_test.go b/internal/version/selfcheck_test.go index b1fdd89..b3e3e26 100644 --- a/internal/version/selfcheck_test.go +++ b/internal/version/selfcheck_test.go @@ -78,7 +78,7 @@ func TestSelfLatestServedFromReleaseStamp(t *testing.T) { } // TestSelfLatestServedFromFullPass verifies the other freshness source: a -// tracked keeptui whose full repo pass already ran makes the self-check free. +// tracked keepkit 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")}) @@ -160,7 +160,7 @@ func TestSelfLatestNoReleases(t *testing.T) { // 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, +// release tuple a tracked keepkit'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. @@ -391,7 +391,7 @@ func TestSelfLatestErrors(t *testing.T) { } // TestSelfLatestPreservesEntry verifies the merge-on-write: the release-only -// pass must not wipe the card/README fields a tracked keeptui already has. +// pass must not wipe the card/README fields a tracked keepkit already has. func TestSelfLatestPreservesEntry(t *testing.T) { apiServer(t, apiHandlers{release: releaseJSON("v0.8.0")}) diff --git a/internal/version/token.go b/internal/version/token.go index 479a48c..82a0139 100644 --- a/internal/version/token.go +++ b/internal/version/token.go @@ -6,7 +6,7 @@ import ( "strings" "sync" - "github.com/stanlyzoolo/keeptui/internal/configdir" + "github.com/stanlyzoolo/keepkit/internal/configdir" ) // testTokenDir overrides the token file directory in tests. @@ -28,7 +28,7 @@ func tokenFilePath() (string, error) { if err != nil { return "", err } - return filepath.Join(base, "keeptui", "token"), nil + return filepath.Join(base, "keepkit", "token"), nil } // loadTokenFromFile reads the token file into tokenMem exactly once. Any error diff --git a/main.go b/main.go index 50638cd..edc94bd 100644 --- a/main.go +++ b/main.go @@ -10,26 +10,26 @@ import ( "runtime/debug" tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/configdir" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/model" - verpkg "github.com/stanlyzoolo/keeptui/internal/version" + "github.com/stanlyzoolo/keepkit/internal/configdir" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/model" + verpkg "github.com/stanlyzoolo/keepkit/internal/version" ) // version is overridden at release time via -ldflags "-X main.version=" // (see .github/workflows/release.yml). It defaults to "dev" for local builds. var version = "dev" -const usage = `keeptui — terminal TUI tracker for CLI tools +const usage = `keepkit — terminal TUI tracker for CLI tools Usage: - keeptui launch the TUI - keeptui --version print version and exit - keeptui --help print this help and exit + keepkit launch the TUI + keepkit --version print version and exit + keepkit --help print this help and exit There are no other flags or subcommands; all interaction happens inside -the TUI. Data lives in the "keeptui" directory under your user config +the TUI. Data lives in the "keepkit" directory under your user config directory (meta.yaml, cache.json, token, logs/). ` @@ -42,7 +42,7 @@ func main() { // handleCLI is the only non-TUI surface. done=false means "no args — launch // the TUI". Anything unrecognized is an error, not a fall-through to the TUI: -// a keeptui probed by another tool (including keeptui itself) with an unknown +// a keepkit probed by another tool (including keepkit itself) with an unknown // flag must fail fast instead of booting a TUI on a detached terminal. func handleCLI(args []string, out, errOut io.Writer) (code int, done bool) { if len(args) == 0 { @@ -50,13 +50,13 @@ func handleCLI(args []string, out, errOut io.Writer) (code int, done bool) { } switch args[0] { case "--version", "-V", "-v", "version": - _, _ = fmt.Fprintf(out, "keeptui %s\n", buildVersion()) + _, _ = fmt.Fprintf(out, "keepkit %s\n", buildVersion()) return 0, true case "--help", "-h", "help": _, _ = fmt.Fprint(out, usage) return 0, true default: - _, _ = fmt.Fprintf(errOut, "keeptui: unknown argument %q\n\n%s", args[0], usage) + _, _ = fmt.Fprintf(errOut, "keepkit: unknown argument %q\n\n%s", args[0], usage) return 2, true } } @@ -83,32 +83,35 @@ func resolveVersion(ldflag, modVersion string) string { return "dev" } -// migrateConfigDir renames the pre-rename config directory (/keys, -// from when the app was called "keys") to the current keeptui config dir. The -// old "keys" data was written under os.UserConfigDir(); the new dir resolves via -// configdir.Base() (~/.config/keeptui on macOS/Linux, %AppData%\keeptui on -// Windows), i.e. wherever the app now reads. One-shot and conservative: if the -// new directory already exists — even empty — nothing is touched, so a -// half-adopted new install is never overwritten by old data. +// migrateConfigDir renames a pre-rename config directory to the current keepkit +// config dir. Two legacy generations exist: "keeptui" (the previous brand) under +// configdir.Base(), and before that "keys" under os.UserConfigDir(). The new dir +// resolves via configdir.Base() (~/.config/keepkit on macOS/Linux, +// %AppData%\keepkit on Windows), i.e. wherever the app now reads. One-shot and +// conservative: if the new directory already exists — even empty — nothing is +// touched, so a half-adopted new install is never overwritten by old data; the +// newest legacy generation wins and the rest is left in place. func migrateConfigDir() { - base, err := os.UserConfigDir() - if err != nil { - return - } newBase, err := configdir.Base() if err != nil { return } - oldDir := filepath.Join(base, "keys") - newDir := filepath.Join(newBase, "keeptui") + newDir := filepath.Join(newBase, "keepkit") if _, err := os.Stat(newDir); err == nil { return } - if _, err := os.Stat(oldDir); err != nil { - return + oldDirs := []string{filepath.Join(newBase, "keeptui")} + if base, err := os.UserConfigDir(); err == nil { + oldDirs = append(oldDirs, filepath.Join(base, "keys")) } - if err := os.Rename(oldDir, newDir); err != nil { - logx.Errorf("config migration %s -> %s: %v", oldDir, newDir, err) + for _, oldDir := range oldDirs { + if _, err := os.Stat(oldDir); err != nil { + continue + } + if err := os.Rename(oldDir, newDir); err != nil { + logx.Errorf("config migration %s -> %s: %v", oldDir, newDir, err) + } + return } } @@ -119,7 +122,7 @@ func runTUI() { logx.Cleanup() // Partial header first, so even a LoadMeta failure gets a non-blank header. ver := buildVersion() - logx.SetHeader(fmt.Sprintf("keeptui %s %s/%s", ver, runtime.GOOS, runtime.GOARCH)) + logx.SetHeader(fmt.Sprintf("keepkit %s %s/%s", ver, runtime.GOOS, runtime.GOARCH)) meta, err := loader.LoadMeta() if err != nil { @@ -128,7 +131,7 @@ func runTUI() { os.Exit(1) } // Enrich the header with tool count and token source now that meta loaded. - logx.SetHeader(fmt.Sprintf("keeptui %s %s/%s tools=%d token=%s", + logx.SetHeader(fmt.Sprintf("keepkit %s %s/%s tools=%d token=%s", ver, runtime.GOOS, runtime.GOARCH, len(meta), verpkg.TokenSource())) p := tea.NewProgram( @@ -155,7 +158,7 @@ func runTUI() { // 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 +// self-check compares against keepkit'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. diff --git a/main_test.go b/main_test.go index 4037750..cb6e462 100644 --- a/main_test.go +++ b/main_test.go @@ -6,9 +6,9 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" - "github.com/stanlyzoolo/keeptui/internal/loader" - "github.com/stanlyzoolo/keeptui/internal/logx" - "github.com/stanlyzoolo/keeptui/internal/model" + "github.com/stanlyzoolo/keepkit/internal/loader" + "github.com/stanlyzoolo/keepkit/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/model" ) func TestHandleCLI(t *testing.T) { @@ -21,10 +21,10 @@ func TestHandleCLI(t *testing.T) { wantErr string // substring expected on stderr }{ {name: "no args launches TUI", args: nil, wantCode: 0, wantDone: false}, - {name: "--version", args: []string{"--version"}, wantCode: 0, wantDone: true, wantOut: "keeptui "}, - {name: "-V", args: []string{"-V"}, wantCode: 0, wantDone: true, wantOut: "keeptui "}, - {name: "-v", args: []string{"-v"}, wantCode: 0, wantDone: true, wantOut: "keeptui "}, - {name: "version word", args: []string{"version"}, wantCode: 0, wantDone: true, wantOut: "keeptui "}, + {name: "--version", args: []string{"--version"}, wantCode: 0, wantDone: true, wantOut: "keepkit "}, + {name: "-V", args: []string{"-V"}, wantCode: 0, wantDone: true, wantOut: "keepkit "}, + {name: "-v", args: []string{"-v"}, wantCode: 0, wantDone: true, wantOut: "keepkit "}, + {name: "version word", args: []string{"version"}, wantCode: 0, wantDone: true, wantOut: "keepkit "}, {name: "--help", args: []string{"--help"}, wantCode: 0, wantDone: true, wantOut: "Usage:"}, {name: "-h", args: []string{"-h"}, wantCode: 0, wantDone: true, wantOut: "Usage:"}, {name: "help word", args: []string{"help"}, wantCode: 0, wantDone: true, wantOut: "Usage:"}, @@ -53,7 +53,7 @@ func TestHandleCLI(t *testing.T) { // The --version line must be parseable by the tool's own installed-version // detection (version.versionRe wants a dotted numeric), so a release build of -// keeptui tracked inside keeptui shows up as installed. +// keepkit tracked inside keepkit shows up as installed. func TestVersionOutputParseable(t *testing.T) { var out strings.Builder oldVersion := version @@ -63,7 +63,7 @@ func TestVersionOutputParseable(t *testing.T) { if code, done := handleCLI([]string{"--version"}, &out, &strings.Builder{}); code != 0 || !done { t.Fatalf("handleCLI --version = (%d, %v), want (0, true)", code, done) } - if got, want := out.String(), "keeptui v0.5.1\n"; got != want { + if got, want := out.String(), "keepkit v0.5.1\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } @@ -115,7 +115,7 @@ func TestNewRootModelInjectsAppVersion(t *testing.T) { } // TestRestartIfRequested pins the other half of the production wiring: the model -// p.Run() returns decides whether keeptui re-execs itself. A stand-in supplies +// p.Run() returns decides whether keepkit 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 { @@ -166,12 +166,12 @@ func (p plainFinal) View() string { return "" } // 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") + dir, err := os.MkdirTemp("", "keepkit-main-logs") if err != nil { panic(err) } restoreLog := logx.SetDirForTesting(dir) - cfgDir, err := os.MkdirTemp("", "keeptui-main-config") + cfgDir, err := os.MkdirTemp("", "keepkit-main-config") if err != nil { panic(err) } diff --git a/restart.go b/restart.go index e2dcecd..2aa1795 100644 --- a/restart.go +++ b/restart.go @@ -1,10 +1,10 @@ package main -// restartHint is what keeptui prints when it cannot replace itself with the +// restartHint is what keepkit 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" +const restartHint = "keepkit updated — run keepkit again" diff --git a/restart_unix.go b/restart_unix.go index 9d235c6..5150aab 100644 --- a/restart_unix.go +++ b/restart_unix.go @@ -11,11 +11,11 @@ import ( "strings" "syscall" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/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 +// terminal tab or tmux pane keepkit 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. @@ -60,7 +60,7 @@ func restartSelfWith(resolve func() (string, error), execve func(string, []strin // 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. +// keepkit'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, "/") { @@ -74,7 +74,7 @@ func resolveSelfPath(executable string, exists func(string) bool, lookPath func( if executable != "" && exists(executable) { return executable, nil } - return "", fmt.Errorf("no keeptui binary to restart (argv0 %q, executable %q)", argv0, executable) + return "", fmt.Errorf("no keepkit binary to restart (argv0 %q, executable %q)", argv0, executable) } // sameProgram reports whether two paths name the same program by base name. An @@ -84,7 +84,7 @@ func resolveSelfPath(executable string, exists func(string) bool, lookPath func( // 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 +// can fire — exec.LookPath("keepkit") on unix never answers "keepkit.exe", and a // backslash in argv0 is an ordinary filename character, not a separator. func sameProgram(a, b string) bool { if b == "" { diff --git a/restart_unix_test.go b/restart_unix_test.go index 92fb737..0eefb91 100644 --- a/restart_unix_test.go +++ b/restart_unix_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/stanlyzoolo/keeptui/internal/logx" + "github.com/stanlyzoolo/keepkit/internal/logx" ) // TestResolveSelfPath pins the argv0 semantics of the restart path resolution. @@ -33,69 +33,69 @@ func TestResolveSelfPath(t *testing.T) { }{ { 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", + executable: "/old/keg/bin/keepkit", + argv0: "keepkit", + existing: []string{"/old/keg/bin/keepkit", "/usr/local/bin/keepkit"}, + pathHit: "/usr/local/bin/keepkit", + want: "/usr/local/bin/keepkit", + wantLookedUp: "keepkit", }, { 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", + executable: "/usr/local/bin/keepkit", + argv0: "keepkit", + existing: []string{"/usr/local/bin/keepkit"}, + want: "/usr/local/bin/keepkit", + wantLookedUp: "keepkit", }, { 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"}, + executable: "/usr/local/bin/keepkit", + argv0: "keepkit", + existing: []string{"/usr/local/bin/keepkit"}, pathHitNoErr: true, - want: "/usr/local/bin/keeptui", - wantLookedUp: "keeptui", + want: "/usr/local/bin/keepkit", + wantLookedUp: "keepkit", }, { // 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", + argv0: "keepkit", pathHitNoErr: true, - wantLookedUp: "keeptui", + wantLookedUp: "keepkit", wantErr: true, }, { name: "a PATH hit for a rewritten argv0 is rejected", - executable: "/usr/local/bin/keeptui", + executable: "/usr/local/bin/keepkit", argv0: "kt-wrapper", - existing: []string{"/usr/local/bin/keeptui", "/usr/bin/kt-wrapper"}, + existing: []string{"/usr/local/bin/keepkit", "/usr/bin/kt-wrapper"}, pathHit: "/usr/bin/kt-wrapper", - want: "/usr/local/bin/keeptui", + want: "/usr/local/bin/keepkit", 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", + executable: "/proc/self/exe/resolved/keepkit", + argv0: "./bin/keepkit", + existing: []string{"./bin/keepkit", "/proc/self/exe/resolved/keepkit"}, + pathHit: "/usr/local/bin/keepkit", + want: "./bin/keepkit", }, { 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", + executable: "/usr/local/bin/keepkit", + argv0: "/old/build/keepkit", + existing: []string{"/usr/local/bin/keepkit"}, + pathHit: "/usr/local/bin/keepkit", // never consulted for a path argv0 + want: "/usr/local/bin/keepkit", }, { name: "everything missed is an error", - executable: "/gone/keeptui", - argv0: "/also/gone/keeptui", + executable: "/gone/keepkit", + argv0: "/also/gone/keepkit", wantErr: true, wantLookedUp: "", }, @@ -105,9 +105,9 @@ func TestResolveSelfPath(t *testing.T) { }, { name: "empty argv0 still uses the executable", - executable: "/usr/local/bin/keeptui", - existing: []string{"/usr/local/bin/keeptui"}, - want: "/usr/local/bin/keeptui", + executable: "/usr/local/bin/keepkit", + existing: []string{"/usr/local/bin/keepkit"}, + want: "/usr/local/bin/keepkit", }, } for _, tt := range tests { @@ -157,19 +157,19 @@ func TestResolveSelfPath(t *testing.T) { // 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. +// must not hijack a restart the user started with ./keepkit. func TestResolveSelfPathNeverLooksUpAPath(t *testing.T) { called := false lookPath := func(string) (string, error) { called = true - return "/usr/local/bin/keeptui", nil + return "/usr/local/bin/keepkit", nil } - got, err := resolveSelfPath("/fallback/keeptui", func(string) bool { return true }, lookPath, "./keeptui") + got, err := resolveSelfPath("/fallback/keepkit", func(string) bool { return true }, lookPath, "./keepkit") if err != nil { t.Fatalf("resolveSelfPath: %v", err) } - if got != "./keeptui" { - t.Errorf("resolveSelfPath = %q, want ./keeptui", got) + if got != "./keepkit" { + t.Errorf("resolveSelfPath = %q, want ./keepkit", got) } if called { t.Error("lookPath was consulted for an argv0 that is already a path") @@ -179,7 +179,7 @@ func TestResolveSelfPathNeverLooksUpAPath(t *testing.T) { // TestFileExists covers the real-filesystem probe handed to resolveSelfPath. func TestFileExists(t *testing.T) { dir := t.TempDir() - file := filepath.Join(dir, "keeptui") + file := filepath.Join(dir, "keepkit") if err := os.WriteFile(file, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write: %v", err) } @@ -217,15 +217,15 @@ func TestRestartSelfWith(t *testing.T) { }{ { name: "exec fails", - resolve: func() (string, error) { return "/usr/local/bin/keeptui", nil }, + resolve: func() (string, error) { return "/usr/local/bin/keepkit", 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", + resolve: func() (string, error) { return "", errors.New("no keepkit binary to restart") }, + wantLog: "no keepkit binary to restart", }, } for _, tt := range tests { @@ -241,7 +241,7 @@ func TestRestartSelfWith(t *testing.T) { return tt.execErr }, &out) - if tt.wantExec && execed != "/usr/local/bin/keeptui" { + if tt.wantExec && execed != "/usr/local/bin/keepkit" { t.Errorf("exec'd %q, want the resolved path", execed) } if !tt.wantExec && execed != "" { @@ -249,7 +249,7 @@ func TestRestartSelfWith(t *testing.T) { } // 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" { + if got := strings.TrimSpace(out.String()); got != "keepkit updated — run keepkit again" { t.Errorf("printed %q, want the restart hint", got) } if log := logx.ReadAllForTesting(logDir); !strings.Contains(log, tt.wantLog) { @@ -269,7 +269,7 @@ func TestRestartSelfWithSuccessIsSilent(t *testing.T) { var out bytes.Buffer restartSelfWith( - func() (string, error) { return "/usr/local/bin/keeptui", nil }, + func() (string, error) { return "/usr/local/bin/keepkit", nil }, func(string, []string, []string) error { return nil }, &out, ) diff --git a/restart_windows.go b/restart_windows.go index 086f57f..ffd01a3 100644 --- a/restart_windows.go +++ b/restart_windows.go @@ -6,7 +6,7 @@ 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 +// hands the new keepkit 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)