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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Each tool has five data sources, split so local detection never waits on the net
`helpMode == helpModeReadme`, so the request is spent per *visited* tool rather than
per tracked tool. The whole `readmeMsg` (content or error) lands in `m.readmeData`, so
a 404 or a rate limit is a session-cached negative; `[r]` in `[2]` clears the entry,
and a token added in the `[L]` overlay drops the rate-limited ones so they can retry.
and a token added in the `[a]` overlay drops the rate-limited ones so they can retry.
The entry appears only when the response lands, so an in-flight request is tracked
separately in `m.readmeLoading` — without it a `j`/`k` bounce back onto the same tool
would spend a second request inside that window.
Expand Down Expand Up @@ -158,8 +158,10 @@ pointer instead of writing through a shared one.
## TUI state machine

Three panels with cycling focus: `[1] tools` (the list), `[2] brief` (the card),
`[3] readme` (the README/`--help`/`man`/update-log view, switched by `r`/`h`/`m` —
`m.helpMode` is global, not per tool, and defaults to the README). Focus moves with `→`/`←`, the digits
`[3] readme` (the README/`--help`/`man`/update-log view, switched by `R`/`H`/`M` —
capitals as a set, so none of them collides with `r` refresh or the lowercase tracker
verbs, and all three fire from `[2]` as well as `[3]`; `m.helpMode` is global, not per
tool, and defaults to the README). Focus moves with `→`/`←`, the digits
`1`/`2`/`3`, or a mouse click; everything goes through `setFocus(f)`, which repaints
the tools list — the only viewport whose content depends on focus.

Expand Down Expand Up @@ -221,21 +223,29 @@ Key invariants:
registered only when its `release notes ↗` affordance actually fit — a heading that
looks like plain text must not open a browser. `handleMouse` rebuilds the map per
click, which is why it can never describe stale content.
- **The status bar is global; each panel owns its footer.** One key list in every
focus instead of three that rewrite themselves as focus moves — what is panel-local
(the card's actions, `[3]`'s paging and entry cursor, `[1]`'s filter and grouping)
sits in that panel's own footer, next to the thing it acts on. All three panels
reserve the same `panelFooterRows` and keep the same `panelGutter` — one blank
column between the frame and everything the panel draws, which in `[2]` and `[3]` is
*all* content: `cardWidth()` and `helpWrapWidth()` are the single definitions of the
two budgets, and `indentLines` applies the indent last, to whole finished lines, so
it can never split an escape sequence or shift a card link's row.
- **The status bar is global; each panel owns its footer.** A key belongs on the bar
exactly when it does the same thing in every focus, and six do: `t` track, `u`
untrack, `m` rename, `a` api, `?` keys, `q` quit (`globalHints`). `enter` and `/`
failed that test — `enter` runs a tool in `[1]`, installs a release in `[2]` and does
nothing in `[3]`; `/` filters the list in `[1]` but searches `[3]`'s text from `[2]`
— so both sit in `[1]`'s footer with `space` group, next to the panel where their
meaning is fixed. Everything else panel-local (the card's actions, `[3]`'s paging and
entry cursor) sits in that panel's footer for the same reason.
The bar lays those six out in **three zones**: keepkit's own name and version
(`appVersionCell`) pinned to the left edge, marked with the same ` ↑` as an outdated
tool row when a newer release is waiting; the hints centered on the bar; the API
quota gauge pinned to the right edge. The collapsed self-update cell rides with the
version, because it carries its action alone (`U update`) and the version cell is the
subject that names what is being updated. Under pressure the bar sheds in order of
how actionable a thing is: trailing hints, then the gauge, then the version, then the
self cell — and the leading hint is truncated rather than allowed to wrap.
All three panels reserve the same `panelFooterRows` and keep the same `panelGutter` —
one blank column between the frame and everything the panel draws, which in `[2]` and
`[3]` is *all* content: `cardWidth()` and `helpWrapWidth()` are the single definitions
of the two budgets, and `indentLines` applies the indent last, to whole finished
lines, so it can never split an escape sequence or shift a card link's row.
`calcListHeight()` is the single definition both the `WindowSizeMsg` handler
and the renderers use, so a drift there cannot push the status bar off screen.
The status bar's right corner carries keepkit's own name and version
(`appVersionCell`), marked with the same ` ↑` as an outdated tool row when a newer
release is waiting — the app announces its own update in the vocabulary it announces
everyone else's.
- **A click's X picks the panel, `panelRow` decides whether it is on one at all.**
The outer margin, the borders and the status bars share the panels' columns; with a
scrolled viewport an unbounded row would map that chrome onto a list row or a card
Expand Down Expand Up @@ -426,7 +436,7 @@ makes the check free. Token: `GITHUB_TOKEN` from the environment always wins ove
"optimistic" observation from `/rate_limit` cannot roll back the per-request
header readings within the same window.
- **`ErrRateLimited`** — a typed error for 403/429 with `X-RateLimit-Remaining: 0`
from the response's own headers; the card shows "rate limited — press [L]",
from the response's own headers; the card shows "rate limited — press [a]",
already-loaded data is not erased.
- **The cache** (`cache.json`, 24h TTL): every read-modify-write goes through
`updateCacheEntry(repo, mutate)` — under a mutex, re-read from disk, merge, write
Expand Down
36 changes: 18 additions & 18 deletions CLAUDE.md

Large diffs are not rendered by default.

27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,18 @@ Pure TUI, no subcommands; the only flags are `--version` and `--help`.
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** — a quota gauge in the status bar, plus token management in
the `L` overlay, lifts the anonymous 60 requests/hour to 5000
the `a` 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

The status bar carries the global keys, each panel's own actions sit in its footer,
and `?` opens the full hotkeys overlay — every keybinding, grouped by panel. That is
all you need to learn. Its right corner names the build you are running
(`keepkit v0.1.0`), and marks it with the same `↑` an outdated tool row carries when
a newer keepkit release is waiting.
The status bar carries the keys that mean the same thing in every panel, each
panel's own actions sit in its footer, and `?` opens the full hotkeys overlay —
every keybinding, grouped by panel. That is all you need to learn. Its left corner
names the build you are running (`keepkit v0.1.0`), marked with the same `↑` an
outdated tool row carries when a newer keepkit release is waiting; the quota gauge
sits in the right corner.

## Installation

Expand Down Expand Up @@ -132,7 +133,8 @@ Run `keepkit` — a three-panel interface opens:
languages, status, tag and note. From here `enter` installs a pending release, the
repo or changelog opens in the browser, and the card data is force-refreshed.
- **`[3] Readme / Help / Man`** — the docs panel: the rendered repository README (the
default), the tool's `--help` output or its `man` page. A README is cleaned up
default, `R`), the tool's `--help` output (`H`) or its `man` page (`M`) — the three
sources are capitals, so none collides with `r` refresh or `m` rename. A README is cleaned up
before rendering — badges, logos, HTML and pictographic emoji go, link text stays
without its URL, the title (and a slogan under it that only repeats the card's own)
is dropped so the panel opens on the first sentence that says something new, and
Expand All @@ -141,9 +143,12 @@ Run `keepkit` — a three-panel interface opens:

Focus moves with `←` / `→` or the digits `1` / `2` / `3` (each panel's number is in
its title, and the focused panel is marked with `▸` as well as by color). The status
bar carries the global keys — `enter` run, `/` search, `t` track, `u` untrack,
`R` rename, `?` keys, `q` quit — while each panel's own actions sit in its footer.
Press `?` any time for the full hotkeys overlay, grouped by panel.
bar carries the six keys that do the same thing in every focus — `t` track, `u`
untrack, `m` rename, `a` api, `?` keys, `q` quit — centered between the running
version on the left and the quota gauge on the right. Everything panel-local sits in
that panel's own footer: `/` filter, `enter` run and `space` group in `[1]`, the
card's actions in `[2]`. 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`), keepkit puts the short tool
Expand Down Expand Up @@ -267,7 +272,7 @@ Quota usage sits in the right corner of the status bar (`api ▮▮░░░░
the whole session once the numbers are known, and the fill turns red as the window
runs out. It is also the only visible sign that keepkit has an API surface at all, so
hiding it at rest hid the overlay with it; on a narrow terminal it is the first thing
the corner drops. The `L` key opens the API status overlay: token source, quota usage with a warning icon
the corner drops. The `a` 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.

Expand Down
14 changes: 7 additions & 7 deletions internal/model/mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ const (
modeSearch // "/" in focusTools: filter the tool list
modeHelpSearch // "/" in focusBrief/focusHelp: search the help viewport
modeEditNote // "e" in focusBrief
modeEditTags // "t" in focusBrief
modeTrack // "t" in focusTools
modeConfirmUntrack // "u" in focusTools
modeRename // "r" in focusTools
modeEditTags // "#" in focusBrief
modeTrack // "t", global
modeConfirmUntrack // "u", global
modeRename // "m", global
modeRunInput // enter in focusTools: run the tool in a new terminal tab
modeConfirmUpdate // "u" in focusBrief: confirm the detected update command
modeAPIStatus // "L": rate-limit / token overlay
modeConfirmUpdate // enter in focusBrief (or "U"): confirm the detected update command
modeAPIStatus // "a": rate-limit / token overlay
modeTokenInput // "e" inside the overlay: masked token entry
modeHotkeys // "?": static hotkeys-help overlay
)
Expand All @@ -43,7 +43,7 @@ func (m Model) apiOverlayVisible() bool {
}

// overlayVisible reports whether any modal overlay is composited over the
// layout — the [L] API-status overlay (incl. token entry) or the [?] hotkeys
// layout — the [a] API-status overlay (incl. token entry) or the [?] hotkeys
// overlay. It is the single "modal on screen" predicate for View() and the
// mouse gate, so a new overlay only has to extend this one helper.
func (m Model) overlayVisible() bool {
Expand Down
108 changes: 70 additions & 38 deletions internal/model/mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,17 @@ func TestModeEnterAndEsc(t *testing.T) {
{"# in brief opens tags edit", focusBrief, keyRunes("#"), modeEditTags},
{"t in tools opens track", focusTools, keyRunes("t"), modeTrack},
{"u in tools opens untrack confirm", focusTools, keyRunes("u"), modeConfirmUntrack},
{"R opens rename", focusTools, keyRunes("R"), modeRename},
{"L opens api status", focusTools, keyRunes("L"), modeAPIStatus},
{"m opens rename", focusTools, keyRunes("m"), modeRename},
{"a opens api status", focusTools, keyRunes("a"), modeAPIStatus},
// The four tracker verbs are what globalHints calls global, so each one
// must open its mode from a panel other than [1] as well — that is the
// whole claim the status bar makes by staying identical in every focus.
{"t is global in brief", focusBrief, keyRunes("t"), modeTrack},
{"u is global in help", focusHelp, keyRunes("u"), modeConfirmUntrack},
{"m is global in brief", focusBrief, keyRunes("m"), modeRename},
{"m is global in help", focusHelp, keyRunes("m"), modeRename},
{"a is global in brief", focusBrief, keyRunes("a"), modeAPIStatus},
{"a is global in help", focusHelp, keyRunes("a"), modeAPIStatus},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -1391,10 +1400,10 @@ func TestHelpNavIdxResetTriggers(t *testing.T) {
t.Run("help/man switch", func(t *testing.T) {
m := helpNavModel()
m.helpNavIdx = 0
updated, _ := m.Update(keyRunes("m"))
updated, _ := m.Update(keyRunes("M"))
nm := updated.(Model)
if nm.helpNavIdx != -1 {
t.Errorf("helpNavIdx = %d, want -1 after [m]", nm.helpNavIdx)
t.Errorf("helpNavIdx = %d, want -1 after [M]", nm.helpNavIdx)
}
})
t.Run("resize", func(t *testing.T) {
Expand Down Expand Up @@ -1465,39 +1474,50 @@ func TestReadmeIsDefaultHelpMode(t *testing.T) {
}
}

// TestReadmeKeyBranches: [r] is the third panel-[3] source switch and fires
// only in focusHelp — rename in [1] and refresh in [2] keep the key.
// TestReadmeKeyBranches: [R] is the third panel-[3] source switch. It fires from
// [2] as well as [3], exactly like [H] and [M] — the asymmetry it used to have
// (readme reachable only from [3], while the [3] title advertised it from both)
// is what let the same keystroke run a network refresh instead. Lowercase r is
// now [2]'s refresh and nothing else, and rename moved off R onto m.
func TestReadmeKeyBranches(t *testing.T) {
t.Run("help focus switches to readme", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := newTestModel(focusHelp)
// Size the viewport and give it enough content to actually scroll:
// on a 0x0 viewport SetYOffset clamps to 0 and the GotoTop assertion
// below would pass no matter what the [r] branch does.
m = mustModel(m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}))
m.focus = focusHelp
m.helpMode = helpModeHelp
m.helpCache["git"] = [2]string{helpModeHelp: strings.Repeat("--flag does a thing\n", 200)}
m.setHelpContent()
m.helpViewport.SetYOffset(3)
if m.helpViewport.YOffset != 3 {
t.Fatalf("setup: YOffset = %d, want 3 (the viewport must be scrollable)", m.helpViewport.YOffset)
}
nm := mustModel(m.Update(keyRunes("r")))
if nm.helpMode != helpModeReadme {
t.Errorf("helpMode = %d, want helpModeReadme", nm.helpMode)
}
if nm.mode != modeNormal {
t.Errorf("mode = %d, want modeNormal (r is not rename in [3])", nm.mode)
}
if nm.helpViewport.YOffset != 0 {
t.Errorf("YOffset = %d, want 0 (GotoTop on the mode switch)", nm.helpViewport.YOffset)
}
})
t.Run("R renames from the tool list", func(t *testing.T) {
for _, focus := range []struct {
name string
f int
}{{"help focus", focusHelp}, {"brief focus", focusBrief}} {
t.Run(focus.name+" switches to readme", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := newTestModel(focus.f)
// Size the viewport and give it enough content to actually scroll:
// on a 0x0 viewport SetYOffset clamps to 0 and the GotoTop assertion
// below would pass no matter what the [R] branch does.
m = mustModel(m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}))
m.focus = focus.f
m.helpMode = helpModeHelp
m.helpCache["git"] = [2]string{helpModeHelp: strings.Repeat("--flag does a thing\n", 200)}
m.setHelpContent()
m.helpViewport.SetYOffset(3)
if m.helpViewport.YOffset != 3 {
t.Fatalf("setup: YOffset = %d, want 3 (the viewport must be scrollable)", m.helpViewport.YOffset)
}
nm := mustModel(m.Update(keyRunes("R")))
if nm.helpMode != helpModeReadme {
t.Errorf("helpMode = %d, want helpModeReadme", nm.helpMode)
}
if nm.focus != focusHelp {
t.Errorf("focus = %d, want focusHelp (the source switch moves focus with it)", nm.focus)
}
if nm.mode != modeNormal {
t.Errorf("mode = %d, want modeNormal (R is not rename any more)", nm.mode)
}
if nm.helpViewport.YOffset != 0 {
t.Errorf("YOffset = %d, want 0 (GotoTop on the mode switch)", nm.helpViewport.YOffset)
}
})
}
t.Run("m renames from the tool list", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := newTestModel(focusTools)
nm := mustModel(m.Update(keyRunes("R")))
nm := mustModel(m.Update(keyRunes("m")))
if nm.mode != modeRename {
t.Errorf("mode = %d, want modeRename", nm.mode)
}
Expand All @@ -1516,12 +1536,24 @@ func TestReadmeKeyBranches(t *testing.T) {
t.Errorf("statusMsg = %q, want the refresh branch's message", nm.statusMsg)
}
})
t.Run("lowercase r no longer switches the source in help", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := newTestModel(focusHelp)
m.helpMode = helpModeHelp
nm := mustModel(m.Update(keyRunes("r")))
if nm.helpMode != helpModeHelp {
t.Errorf("helpMode = %d, want it unchanged: r is [2]'s refresh, not [3]'s readme", nm.helpMode)
}
if nm.statusMsg != "" {
t.Errorf("statusMsg = %q, want none (r is unbound in [3])", nm.statusMsg)
}
})
t.Run("readme fetch fires when the tool was never fetched", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := New([]loader.ToolMeta{{Name: "rg", GitHub: "BurntSushi/ripgrep"}})
m.width, m.height = 80, 24
m.helpMode, m.focus = helpModeMan, focusHelp
updated, cmd := m.Update(keyRunes("r"))
updated, cmd := m.Update(keyRunes("R"))
if updated.(Model).helpMode != helpModeReadme {
t.Fatalf("helpMode = %d, want helpModeReadme", updated.(Model).helpMode)
}
Expand All @@ -1531,15 +1563,15 @@ func TestReadmeKeyBranches(t *testing.T) {
})
}

// TestReadmeKeyUpdateLogPriority mirrors [h]/[m]: an explicit [r] dismisses a
// TestReadmeKeyUpdateLogPriority mirrors [H]/[M]: an explicit [R] dismisses a
// finished update log, but a live one keeps panel [3].
func TestReadmeKeyUpdateLogPriority(t *testing.T) {
t.Run("live log survives", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
m := newTestModel(focusHelp)
m.updateLogFor, m.updatingFor = "git", "git"
m.updateLog = []string{"==> brew upgrade"}
nm := mustModel(m.Update(keyRunes("r")))
nm := mustModel(m.Update(keyRunes("R")))
if nm.updateLogFor != "git" {
t.Fatalf("updateLogFor = %q, want the live log kept", nm.updateLogFor)
}
Expand All @@ -1552,7 +1584,7 @@ func TestReadmeKeyUpdateLogPriority(t *testing.T) {
m := newTestModel(focusHelp)
m.updateLogFor = "git"
m.updateLog = []string{"==> brew upgrade"}
nm := mustModel(m.Update(keyRunes("r")))
nm := mustModel(m.Update(keyRunes("R")))
if nm.updateLogFor != "" {
t.Errorf("updateLogFor = %q, want the completed log dismissed", nm.updateLogFor)
}
Expand Down
Loading
Loading